Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/app/.ladle/settings-story-fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
type UpdateInventoryMachine,
} from "../src/hooks/useUpdateInventory";
import { createAppQueryClient } from "../src/lib/query-client";
import { getSettingsProviderRoutePath } from "../src/lib/route-paths";
import { getSettingsRoutePath } from "../src/lib/route-paths";
import {
BbAppUpdateRows,
MachineUpdatesRows,
Expand Down Expand Up @@ -205,9 +205,7 @@ export function SettingsUpdatesStory() {
runningJobKey={null}
queuedJobKeys={noJobs}
onStartInstall={noop}
onOpenProvider={(providerId) =>
navigate(getSettingsProviderRoutePath(providerId))
}
onOpenProvider={() => navigate(getSettingsRoutePath("providers"))}
/>
</MachineUpdatesSection>
</div>
Expand Down
21 changes: 1 addition & 20 deletions apps/app/.ladle/story-settings-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { AppPageHeader } from "@/components/layout/AppPageHeader";
import { SettingsSidebarContent } from "@/components/settings/SettingsSidebar";
import {
SETTINGS_NAV_SECTIONS,
SETTINGS_PROVIDER_ENTRIES,
type SettingsSectionId,
} from "@/components/settings/settings-nav";
import {
Expand All @@ -16,13 +15,11 @@ import { PageShell } from "@/components/ui/page-shell";
import {
SETTINGS_ROUTE_PATH,
SETTINGS_MACHINE_ROUTE_PATH,
getSettingsProviderRoutePath,
getSettingsRoutePath,
} from "@/lib/route-paths";

export type SettingsStoryRoute =
| { kind: "machine"; id: string }
| { kind: "provider"; id: (typeof SETTINGS_PROVIDER_ENTRIES)[number]["id"] }
| { kind: "section"; id: SettingsSectionId };

/** Resolve the story's real Settings links without depending on live app data. */
Expand All @@ -32,13 +29,6 @@ export function useSettingsStoryRoute(): SettingsStoryRoute {
if (machineMatch?.params.hostId !== undefined) {
return { kind: "machine", id: machineMatch.params.hostId };
}
const provider = SETTINGS_PROVIDER_ENTRIES.find(
(entry) => getSettingsProviderRoutePath(entry.id) === pathname,
);
if (provider !== undefined) {
return { kind: "provider", id: provider.id };
}

const section = SETTINGS_NAV_SECTIONS.find((entry) =>
entry.id === "general"
? pathname === SETTINGS_ROUTE_PATH
Expand All @@ -60,14 +50,7 @@ export function SettingsStoryChrome({
}) {
const route = useSettingsStoryRoute();
const resolvedActiveSection =
activeSection ??
(route.kind === "section"
? route.id
: route.kind === "machine"
? "machines"
: null);
const activeProviderId =
activeSection === undefined && route.kind === "provider" ? route.id : null;
activeSection ?? (route.kind === "section" ? route.id : "machines");

return (
<SidebarProvider
Expand All @@ -79,10 +62,8 @@ export function SettingsStoryChrome({
isResizing={false}
navigation={{
activePluginId: null,
activeProviderId,
activeSection: resolvedActiveSection,
pluginEntries: [],
providerEntries: SETTINGS_PROVIDER_ENTRIES,
sections: SETTINGS_NAV_SECTIONS,
}}
onResizeMouseDown={() => {}}
Expand Down
5 changes: 0 additions & 5 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
SETTINGS_PLUGIN_ROUTE_PATH,
SETTINGS_PLUGINS_ROUTE_PATH,
SETTINGS_MACHINE_ROUTE_PATH,
SETTINGS_PROVIDER_ROUTE_PATH,
SETTINGS_ROUTE_PATH,
SETTINGS_SECTION_ROUTE_PATH,
SKILLS_ROUTE_PATH,
Expand Down Expand Up @@ -253,10 +252,6 @@ function AppRoutes() {
path={SETTINGS_MACHINE_ROUTE_PATH}
element={<MachineSettingsView />}
/>
<Route
path={SETTINGS_PROVIDER_ROUTE_PATH}
element={<SettingsView />}
/>
<Route
path={PROJECT_SETTINGS_ROUTE_PATH}
element={<ProjectSettingsView />}
Expand Down
11 changes: 7 additions & 4 deletions apps/app/src/components/pickers/ModelReasoningPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ModelReasoningPicker,
} from "./ModelReasoningPicker";
import type { PickerOption } from "./OptionPicker";
import type { ProviderPickerOption } from "./model-brand-prefix";
import type { ModelPickerOption } from "./model-picker-option";

type CapturedCommandHandler = (invocation: {
Expand Down Expand Up @@ -61,9 +62,11 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({
useIsAppCommandModifierHeld: () => false,
}));

const providerOptions: readonly PickerOption<string>[] = [
{ value: "codex", label: "Codex" },
{ value: "claude-code", label: "Claude Code" },
// The brand prefix comes from each provider's declared strings; the picker
// strips it from model labels under that provider's tab.
const providerOptions: readonly ProviderPickerOption[] = [
{ value: "codex", label: "Codex", brandPrefix: "GPT-" },
{ value: "claude-code", label: "Claude Code", brandPrefix: "Claude " },
];

const codexModels: readonly PickerOption<string>[] = [
Expand Down Expand Up @@ -164,7 +167,7 @@ function renderPicker({
pickerReasoningOptions?: readonly PickerOption<ReasoningLevel>[];
reasoningValue?: ReasoningLevel;
moreModelOptions?: readonly ModelPickerOption[];
pickerProviderOptions?: readonly PickerOption<string>[];
pickerProviderOptions?: readonly ProviderPickerOption[];
alternateProviderModels?: AvailableModel[];
providerRouting?: SystemProvidersQuery;
selectedProviderId?: string;
Expand Down
39 changes: 23 additions & 16 deletions apps/app/src/components/pickers/ModelReasoningPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import type {
SystemProvidersQuery,
} from "@bb/server-contract";
import { type ReasoningLevel } from "@bb/domain";
import { stripModelBrandPrefix } from "./model-brand-prefix";
import {
stripModelBrandPrefix,
type ProviderPickerOption,
} from "./model-brand-prefix";
import { REASONING_LABELS } from "@/lib/reasoning-labels";
import { Button } from "@bb/shared-ui/button";
import { Icon, type IconName } from "@bb/shared-ui/icon";
Expand Down Expand Up @@ -143,9 +146,9 @@ function fuzzyFilter<T>(
// stripped from the rendered row, surprising the user.
function modelSearchText(
option: ModelPickerOption,
providerId: string,
brandPrefix: string | undefined,
): string {
return `${stripModelBrandPrefix(option.label, providerId)} ${option.routeProviderId ?? ""} ${option.value}`;
return `${stripModelBrandPrefix(option.label, brandPrefix)} ${option.routeProviderId ?? ""} ${option.value}`;
}

/**
Expand Down Expand Up @@ -203,7 +206,7 @@ export function buildModelNavRows({
interface ModelReasoningPickerProps {
// Provider state
providerRouting?: SystemProvidersQuery;
providerOptions: readonly PickerOption<string>[];
providerOptions: readonly ProviderPickerOption[];
selectedProviderId: string;
/** Omit to render the provider as locked (tabs hidden, can't switch). */
onSelectedProviderChange?: (value: string) => void;
Expand Down Expand Up @@ -345,7 +348,7 @@ export function ModelReasoningPicker({
const triggerModelLabel = modelIsLoading
? "Loading models..."
: hasSelectedModel
? stripModelBrandPrefix(selectedModelLabel, selectedProviderId)
? stripModelBrandPrefix(selectedModelLabel, selectedProvider?.brandPrefix)
: selectedModelLoadFailed
? hasAlternateSelectionPath
? "Select model"
Expand Down Expand Up @@ -477,17 +480,18 @@ export function ModelReasoningPicker({
// Filtered model lists (client-side fuzzy search scoped to the active
// provider). Matching uses the rendered (brand-stripped) label plus the model
// id so search reflects what the user sees.
const activeBrandPrefix = activeProvider?.brandPrefix;
const filteredModelOptions = useMemo(() => {
return fuzzyFilter(activeModelOptions, normalizedQuery, (option) =>
modelSearchText(option, activeProviderId),
modelSearchText(option, activeBrandPrefix),
);
}, [activeModelOptions, normalizedQuery, activeProviderId]);
}, [activeModelOptions, normalizedQuery, activeBrandPrefix]);

const filteredMoreModelOptions = useMemo(() => {
return fuzzyFilter(activeMoreModelOptions, normalizedQuery, (option) =>
modelSearchText(option, activeProviderId),
modelSearchText(option, activeBrandPrefix),
);
}, [activeMoreModelOptions, normalizedQuery, activeProviderId]);
}, [activeMoreModelOptions, normalizedQuery, activeBrandPrefix]);

// The single navigable-row model that drives arrow keys, Enter, active
// highlighting, and active-descendant ids.
Expand Down Expand Up @@ -1035,11 +1039,11 @@ export function ModelReasoningPicker({
role={showSearchInput ? "option" : undefined}
isActive={active}
// The menu always reflects the provider whose models it
// lists (committed or previewed) — strip with
// `activeProviderId`.
// lists (committed or previewed) — strip with the
// active provider's declared prefix.
label={stripModelBrandPrefix(
option.label,
activeProviderId,
activeBrandPrefix,
)}
qualifier={option.routeProviderId}
selected={!isPreviewing && option.value === modelValue}
Expand All @@ -1058,7 +1062,7 @@ export function ModelReasoningPicker({
open={moreModelsOpen}
onOpenChange={setMoreModelsOpen}
openSub={openSub}
activeProviderId={activeProviderId}
activeBrandPrefix={activeBrandPrefix}
isPreviewing={isPreviewing}
modelValue={modelValue}
options={filteredMoreModelOptions}
Expand Down Expand Up @@ -1088,6 +1092,9 @@ export function ModelReasoningPicker({
<ModelLoadErrorMessage
error={activeModelLoadError}
providerLabel={activeProviderLabel}
{...(activeProvider?.installUrl === undefined
? {}
: { installUrl: activeProvider.installUrl })}
/>
) : activeModelLoadFailed ? (
activeModelFailureMessage
Expand Down Expand Up @@ -1254,7 +1261,7 @@ function MoreModelsSubmenu({
open,
onOpenChange,
openSub,
activeProviderId,
activeBrandPrefix,
isPreviewing,
modelValue,
options,
Expand All @@ -1263,7 +1270,7 @@ function MoreModelsSubmenu({
open: boolean;
onOpenChange: (open: boolean) => void;
openSub: () => void;
activeProviderId: string;
activeBrandPrefix: string | undefined;
isPreviewing: boolean;
modelValue: string;
options: readonly ModelPickerOption[];
Expand Down Expand Up @@ -1354,7 +1361,7 @@ function MoreModelsSubmenu({
{options.map((option) => (
<MenuRowButton
key={option.value}
label={stripModelBrandPrefix(option.label, activeProviderId)}
label={stripModelBrandPrefix(option.label, activeBrandPrefix)}
qualifier={option.routeProviderId}
selected={!isPreviewing && option.value === modelValue}
onClick={() => onSelect(option.value)}
Expand Down
45 changes: 29 additions & 16 deletions apps/app/src/components/pickers/model-brand-prefix.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import type { PickerOption } from "./OptionPicker";

/**
* Drops the brand prefix from a model label once provider context is
* unambiguous (the trigger shows the provider icon; the menu shows provider
* tabs above the model list). "Sonnet 4.6" / "5.5" reads cleaner than
* "Claude Sonnet 4.6" / "GPT-5.5".
*
* Lives at the picker's render site rather than in `formatModelLabel` so
* stories — which hand picker labels in directly — see the same trigger and
* menu output as production paths that go through the formatter.
* A provider tab in the model picker: the generic picker option plus the
* provider's declared copy the composer renders — `strings.brandPrefix`, so
* the rows under it can drop the brand once provider context is unambiguous
* (the trigger shows the provider icon; the menu shows provider tabs above
* the model list; "Sonnet 4.6" / "5.5" reads cleaner than "Claude Sonnet
* 4.6" / "GPT-5.5"), `strings.planModeCopy`, which marks a provider whose
* plan mode changes the permission display, and `strings.installUrl`.
*/
export interface ProviderPickerOption extends PickerOption<string> {
brandPrefix?: string;
planModeCopy?: string;
/** Where to install the provider's CLI, for the missing-executable hint. */
installUrl?: string;
}

/**
* Drops the provider's declared brand prefix from a model label. A provider
* that declares none keeps its labels whole. Lives at the picker's render
* site rather than in `formatModelLabel` so stories — which hand picker
* labels in directly — see the same trigger and menu output as production
* paths that go through the formatter.
*/
export function stripModelBrandPrefix(
label: string,
providerId: string,
brandPrefix: string | undefined,
): string {
switch (providerId) {
case "claude-code":
return label.replace(/^Claude\s+/i, "");
case "codex":
return label.replace(/^GPT-/i, "");
default:
return label;
if (brandPrefix === undefined || brandPrefix.length === 0) {
return label;
}
return label.toLowerCase().startsWith(brandPrefix.toLowerCase())
? label.slice(brandPrefix.length).trimStart()
: label;
}
41 changes: 10 additions & 31 deletions apps/app/src/components/pickers/model-load-error-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,15 @@ import { useUrlAnchorClickHandler } from "@/lib/url-open-routing";
interface ModelLoadErrorMessageProps {
error: SystemExecutionOptionsModelLoadError;
providerLabel: string;
/** The provider's declared `strings.installUrl`, when it declares one. */
installUrl?: string;
}

interface FormatModelLoadErrorTextArgs {
error: SystemExecutionOptionsModelLoadError;
providerLabel: string;
}

const PROVIDER_CLI_HELP_LINKS: Partial<
Record<string, { label: string; url: string }>
> = {
codex: {
label: "Codex CLI",
url: "https://developers.openai.com/codex/cli",
},
"acp-cursor": {
label: "Cursor CLI",
url: "https://cursor.com/docs/cli/installation",
},
};

function providerCliLabel({
error,
providerLabel,
}: FormatModelLoadErrorTextArgs): string {
return (
PROVIDER_CLI_HELP_LINKS[error.providerId]?.label ?? `${providerLabel} CLI`
);
}

export function formatModelLoadErrorText({
error,
providerLabel,
Expand All @@ -47,7 +27,7 @@ export function formatModelLoadErrorText({
}

if (error.code === "missing_executable") {
return `Could not load models for ${providerLabel}. Please make sure the ${providerCliLabel({ error, providerLabel })} is installed.`;
return `Could not load models for ${providerLabel}. Please make sure the ${providerLabel} CLI is installed.`;
}

if (error.code === "auth_required") {
Expand All @@ -60,28 +40,27 @@ export function formatModelLoadErrorText({
export function ModelLoadErrorMessage({
error,
providerLabel,
installUrl,
}: ModelLoadErrorMessageProps): ReactNode {
const helpLink =
error.code === "missing_executable"
? PROVIDER_CLI_HELP_LINKS[error.providerId]
: undefined;
const handleHelpLinkClick = useUrlAnchorClickHandler(helpLink?.url);
const helpUrl =
error.code === "missing_executable" ? installUrl : undefined;
const handleHelpLinkClick = useUrlAnchorClickHandler(helpUrl);

if (error.code === "missing_executable") {
if (!helpLink) {
if (helpUrl === undefined) {
return formatModelLoadErrorText({ error, providerLabel });
}
return (
<>
Could not load models for {providerLabel}. Please make sure the{" "}
<a
href={helpLink.url}
href={helpUrl}
target="_blank"
rel="noreferrer"
onClick={handleHelpLinkClick}
className="underline underline-offset-2 hover:text-foreground"
>
{helpLink.label}
{providerLabel} CLI
</a>{" "}
is installed.
</>
Expand Down
Loading
Loading