Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,7 @@ export function ProvidersSettingsSection({
) : (
<SettingsRowList>
{providers.map((provider, index) => {
const ProviderIcon = getProviderIconInfo(
provider.id,
provider.logoUrl,
)?.icon;
const ProviderIcon = getProviderIconInfo(provider.id, provider)?.icon;
const isDefault =
generalSettings.defaultProviderId === provider.id ||
(generalSettings.defaultProviderId === null && index === 0);
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/hooks/useThreadCreationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ export function useThreadCreationOptions(
providers.map((p) => ({
value: p.id,
label: p.displayName,
icon: getProviderIconInfo(p.id, p.logoUrl ?? null)?.icon,
icon: getProviderIconInfo(p.id, p)?.icon,
...(p.strings?.brandPrefix === undefined
? {}
: { brandPrefix: p.strings.brandPrefix }),
Expand Down
77 changes: 61 additions & 16 deletions apps/app/src/lib/provider-icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,16 @@ afterEach(() => {

describe("getProviderIconInfo", () => {
it("prefers a configured provider logo over the generic ACP icon", () => {
const iconInfo = getProviderIconInfo(
"acp-do-computer",
"/api/v1/system/providers/acp-do-computer/logo",
);
const iconInfo = getProviderIconInfo("acp-do-computer", {
logoUrl: "/api/v1/system/providers/acp-do-computer/logo",
});
if (iconInfo === undefined) {
throw new Error("Expected configured provider logo icon info");
}
expect(
getProviderIconInfo(
"acp-do-computer",
"/api/v1/system/providers/acp-do-computer/logo",
)?.icon,
getProviderIconInfo("acp-do-computer", {
logoUrl: "/api/v1/system/providers/acp-do-computer/logo",
})?.icon,
).toBe(iconInfo.icon);

const view = render(
Expand All @@ -71,10 +69,9 @@ describe("getProviderIconInfo", () => {
// resolves to black there, invisible on dark themes. Known ids must keep
// their inline React marks even when the server provides a logoUrl.
for (const providerId of ["codex", "claude-code", "pi", "acp-opencode"]) {
const iconInfo = getProviderIconInfo(
providerId,
`/api/v1/system/providers/${providerId}/logo`,
);
const iconInfo = getProviderIconInfo(providerId, {
logoUrl: `/api/v1/system/providers/${providerId}/logo`,
});
if (iconInfo === undefined) {
throw new Error(`Expected icon info for ${providerId}`);
}
Expand All @@ -85,11 +82,59 @@ describe("getProviderIconInfo", () => {
}
});

it("lets a plugin-registered component win, and falls back when it goes away", () => {
const iconInfo = getProviderIconInfo(
"codex",
"/api/v1/system/providers/codex/logo",
it("draws a declared host glyph for a provider without a logo, and keeps it below a logo", () => {
// `icon: "Zap"` on the declaration: no bytes to serve, so no logoUrl; the
// glyph arrives by name and must render through the shared icon set
// (inline svg, inherits the text color) instead of the initial.
const glyphInfo = getProviderIconInfo("echo-agent", {
logoUrl: null,
icon: { glyph: "Zap" },
});
if (glyphInfo === undefined) {
throw new Error("Expected a glyph icon for echo-agent");
}
expect(
getProviderIconInfo("echo-agent", { logoUrl: null, icon: { glyph: "Zap" } })
?.icon,
).toBe(glyphInfo.icon);
const glyphView = render(
createElement(glyphInfo.icon, { className: "size-4" }),
);
expect(glyphView.container.querySelector("img")).toBeNull();
expect(
glyphView.container.querySelector('svg[data-icon="Zap"]'),
).not.toBeNull();
glyphView.unmount();

// A glyph the host does not know resolves to nothing, so the caller's
// fallback (the initial) takes over instead of an empty box.
expect(
getProviderIconInfo("echo-agent", {
logoUrl: null,
icon: { glyph: "NoSuchGlyph" },
}),
).toBeUndefined();

// A file logo is the richer asset: it wins when both are present.
const bothInfo = getProviderIconInfo("echo-agent", {
logoUrl: "/api/v1/system/providers/echo-agent/logo",
icon: { glyph: "Zap" },
});
if (bothInfo === undefined) {
throw new Error("Expected icon info when both forms are present");
}
const bothView = render(createElement(bothInfo.icon, {}));
expect(bothView.container.querySelector("img")).not.toBeNull();
bothView.unmount();

// Unknown non-ACP provider with neither form: nothing, as before.
expect(getProviderIconInfo("echo-agent", { logoUrl: null })).toBeUndefined();
});

it("lets a plugin-registered component win, and falls back when it goes away", () => {
const iconInfo = getProviderIconInfo("codex", {
logoUrl: "/api/v1/system/providers/codex/logo",
});
if (iconInfo === undefined) {
throw new Error("Expected icon info for codex");
}
Expand Down
84 changes: 72 additions & 12 deletions apps/app/src/lib/provider-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { OpenAiIcon } from "@/components/icons/OpenAiIcon";
import { OpencodeIcon } from "@/components/icons/OpencodeIcon";
import { OmpIcon } from "@/components/icons/OmpIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import { Icon } from "@bb/shared-ui/icon";
import { Icon, ICON_NAMES, type IconName } from "@bb/shared-ui/icon";
import { getPluginSlotSnapshot, subscribePluginSlots } from "./plugin-slots";

const ACP_ID_PREFIX = "acp-";
Expand All @@ -25,6 +25,44 @@ function isAcpProviderId(providerId: string): boolean {
const GenericAcpIcon: ComponentType<{ className?: string }> = ({ className }) =>
createElement(Icon, { name: "Code", className, "aria-hidden": "true" });

/**
* What a caller knows about a provider's declared icon, straight off its
* `ProviderInfo`: a file logo served by the host (`logoUrl`) or a named host
* glyph (`icon.glyph`). A declaration names at most one.
*/
export interface ProviderIconSource {
logoUrl: string | null;
icon?: { glyph: string };
}

function isIconName(name: string): name is IconName {
return (ICON_NAMES as readonly string[]).includes(name);
}

const declaredGlyphIcons = new Map<string, ComponentType<{ className?: string }>>();

/**
* A provider's declared host glyph, rendered through the shared icon set so
* it inherits the surrounding text color like a vendored brand mark. An
* unknown glyph name (a newer host's vocabulary, a typo) resolves to nothing,
* and the caller's fallback chain continues.
*/
function getDeclaredGlyphIcon(
glyph: string,
): ComponentType<{ className?: string }> | undefined {
if (!isIconName(glyph)) {
return undefined;
}
const cached = declaredGlyphIcons.get(glyph);
if (cached !== undefined) {
return cached;
}
const GlyphIcon: ComponentType<{ className?: string }> = ({ className }) =>
createElement(Icon, { name: glyph, className, "aria-hidden": "true" });
declaredGlyphIcons.set(glyph, GlyphIcon);
return GlyphIcon;
}

// Vendored brand marks for the built-in providers, keyed by provider id. The
// first-party provider plugins ship no frontend bundle: registering these
// same marks through `app.slots.experimental_providerIcon` cost four JS+CSS
Expand Down Expand Up @@ -66,7 +104,9 @@ function getConfiguredProviderLogoIcon(
return cached;
}

const fallbackIcon = resolveStaticProviderIconInfo(providerId, null)?.icon;
const fallbackIcon = resolveStaticProviderIconInfo(providerId, {
logoUrl: null,
})?.icon;
const ProviderLogoIcon: ComponentType<{ className?: string }> = ({
className,
}) => {
Expand Down Expand Up @@ -110,10 +150,10 @@ const pluginAwareProviderIcons = new Map<
*/
function getPluginAwareProviderIcon(
providerId: string,
logoUrl: string | null,
source: ProviderIconSource,
staticIcon: ComponentType<{ className?: string }> | undefined,
): ComponentType<{ className?: string }> {
const cacheKey = `${providerId}\0${logoUrl ?? ""}`;
const cacheKey = `${providerId}\0${source.logoUrl ?? ""}\0${source.icon?.glyph ?? ""}`;
const cached = pluginAwareProviderIcons.get(cacheKey);
if (cached !== undefined) {
return cached;
Expand Down Expand Up @@ -152,29 +192,41 @@ function getPluginAwareProviderIcon(
* 3. A caller-supplied `logoUrl` (from a server-provided `ProviderInfo`) for
* providers without a vendored mark — plugin-registered third parties, and
* the right home for static color logos.
* 4. The generic glyph for unrecognized ACP providers.
* 4. The host glyph the provider declared (`ProviderInfo.icon.glyph`, from a
* declaration like `icon: "Zap"`): a plugin without an SVG asset still
* gets a mark instead of its initial. Drawn through the shared icon set,
* so it inherits the text color like a vendored mark.
* 5. The generic glyph for unrecognized ACP providers.
*
* Returns undefined for unknown non-ACP providers so callers can fall back
* gracefully.
* gracefully (the picker shows the display name's initial).
*
* The second argument is the provider's declared icon source — pass the
* `ProviderInfo` itself, or nothing for surfaces that only know the id.
*/
export function getProviderIconInfo(
providerId: string,
logoUrl: string | null = null,
source: ProviderIconSource | null = null,
): ProviderIconInfo | undefined {
const staticInfo = resolveStaticProviderIconInfo(providerId, logoUrl);
const resolvedSource = source ?? { logoUrl: null };
const staticInfo = resolveStaticProviderIconInfo(providerId, resolvedSource);
const pluginIcon = getRegisteredPluginProviderIcon(providerId);
if (staticInfo === undefined && pluginIcon === undefined) {
return undefined;
}
return {
icon: getPluginAwareProviderIcon(providerId, logoUrl, staticInfo?.icon),
icon: getPluginAwareProviderIcon(
providerId,
resolvedSource,
staticInfo?.icon,
),
ariaLabel: staticInfo?.ariaLabel ?? providerId,
};
}

function resolveStaticProviderIconInfo(
providerId: string,
logoUrl: string | null,
source: ProviderIconSource,
): ProviderIconInfo | undefined {
const builtInBrand = BUILT_IN_BRAND_ICONS[providerId];
if (builtInBrand !== undefined) {
Expand All @@ -189,13 +241,21 @@ function resolveStaticProviderIconInfo(
}
}

if (logoUrl !== null) {
if (source.logoUrl !== null) {
return {
icon: getConfiguredProviderLogoIcon(providerId, logoUrl),
icon: getConfiguredProviderLogoIcon(providerId, source.logoUrl),
ariaLabel: "Provider logo",
};
}

const glyphIcon =
source.icon === undefined
? undefined
: getDeclaredGlyphIcon(source.icon.glyph);
if (glyphIcon !== undefined) {
return { icon: glyphIcon, ariaLabel: "Provider icon" };
}

if (isAcpProviderId(providerId)) {
return { icon: GenericAcpIcon, ariaLabel: "ACP provider" };
}
Expand Down
39 changes: 38 additions & 1 deletion apps/mobile/src/data/compose/execution-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { AvailableModel } from "@bb/domain";
import type { AvailableModel, ProviderInfo } from "@bb/domain";
import type { SystemExecutionOptionsResponse } from "@bb/server-contract";
import { describe, expect, it } from "vitest";
import {
buildPermissionModeOptions,
buildProviderOptions,
buildReasoningOptions,
formatModelLabel,
resolveModelSelection,
Expand Down Expand Up @@ -204,6 +205,42 @@ describe("permission modes", () => {
});
});

describe("buildProviderOptions", () => {
it("carries the declared glyph beside the logo so the picker can draw either", () => {
const base: Omit<ProviderInfo, "id" | "displayName" | "logoUrl"> = {
available: true,
experimental_providerHealth: false,
experimental_providerUsage: false,
experimental_providerInstallation: false,
capabilities: {
supportsThreadArchive: false,
supportsThreadRename: false,
supportsServiceTier: false,
supportsNativeUserQuestion: false,
supportsFork: false,
supportsSessionRewind: false,
permissionModes: ["full"],
},
composerActions: [],
};
expect(
buildProviderOptions([
{ ...base, id: "codex", displayName: "Codex", logoUrl: "/logo" },
{
...base,
id: "echo-agent",
displayName: "Echo",
logoUrl: null,
icon: { glyph: "Zap" },
},
]).map(({ value, logoUrl, glyph }) => ({ value, logoUrl, glyph })),
).toEqual([
{ value: "codex", logoUrl: "/logo", glyph: null },
{ value: "echo-agent", logoUrl: null, glyph: "Zap" },
]);
});
});

describe("formatModelLabel", () => {
it("title-cases hyphenated ids and keeps version numbers", () => {
expect(formatModelLabel("gpt-5.4-mini")).toBe("GPT-5.4-Mini");
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/data/compose/execution-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface ProviderPickerOption {
value: string;
label: string;
logoUrl: string | null;
/** The host glyph the provider declared, when it shipped no logo file. */
glyph: string | null;
available: boolean;
}

Expand Down Expand Up @@ -87,6 +89,7 @@ export function buildProviderOptions(
value: provider.id,
label: provider.displayName,
logoUrl: provider.logoUrl,
glyph: provider.icon?.glyph ?? null,
available: provider.available,
}));
}
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/src/screens/pickers/ProviderPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import type { ProviderPickerOption } from "@/data/compose";
import { useTheme } from "@/theme";
import { useSheet } from "@/ui";
import { isIconName, useSheet, type IconName } from "@/ui";
import { ServerSvgIcon } from "../plugins/ServerSvgIcon";
import { OptionSheet, type PickerOption } from "./OptionSheet";
import { PickerTrigger } from "./PickerTrigger";
Expand All @@ -17,8 +17,16 @@ interface ProviderPickerProps {
/**
* Agent provider (Codex, Claude, …) picker. Provider logos come from the
* server (`GET /system/providers/:id/logo`, `currentColor` SVGs) painted in
* the theme foreground; a provider without a logo gets the Zap glyph.
* the theme foreground; a provider that declared a named glyph instead of a
* logo file (`icon: "Zap"` on its declaration) gets that glyph when this app
* knows it, and any other provider gets the Zap glyph.
*/
function providerGlyph(option: ProviderPickerOption): IconName {
return option.glyph !== null && isIconName(option.glyph)
? option.glyph
: "Zap";
}

export function ProviderPicker({
options,
value,
Expand All @@ -33,7 +41,7 @@ export function ProviderPicker({
options.map((option) => ({
value: option.value,
label: option.label,
icon: "Zap",
icon: providerGlyph(option),
leading:
option.logoUrl === null ? undefined : (
<ServerSvgIcon
Expand All @@ -55,7 +63,7 @@ export function ProviderPicker({
return (
<>
<PickerTrigger
icon="Zap"
icon={selected === undefined ? "Zap" : providerGlyph(selected)}
leading={
selected?.logoUrl ? (
<ServerSvgIcon
Expand Down
Loading
Loading