diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index f833baacd7c3..adcba32489b5 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -5,6 +5,7 @@ import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider import { useState } from "react"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { ProviderBadge } from "../lib/modelOptions"; import { AppText as Text } from "./AppText"; type ProviderIconProps = { @@ -222,3 +223,26 @@ export function ProviderInstanceIcon(props: { ); } + +/** A provider glyph that carries the account badge when its instance needs one. */ +export function ProviderAccountIcon(props: { + readonly provider: string | null | undefined; + readonly iconUrl?: string | null | undefined; + readonly badge: ProviderBadge | undefined; + readonly size: number; + readonly surfaceColor: string; +}) { + return props.badge ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 181c257cbc17..58fdf0ca714d 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -63,7 +63,7 @@ import { } from "../../state/composer-attachment-uploads"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderAccountIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; @@ -659,6 +659,7 @@ export function NewTaskDraftScreen(props: { const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; + const composerSurfaceColor = theme["--color-composer-surface"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); @@ -1763,14 +1764,20 @@ export function NewTaskDraftScreen(props: { ( - )} label={flow.selectedModelOption?.label ?? "Choose model"} diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index fc93df0cd7a9..9b79fc3ed730 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -6,7 +6,8 @@ import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; import { View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderAccountIcon } from "../../components/ProviderIcon"; +import type { ProviderBadge } from "../../lib/modelOptions"; import { RequestActionButton } from "./RequestActionButton"; import { useVisibleSecondClock } from "./use-visible-second-clock"; @@ -16,8 +17,12 @@ import { useVisibleSecondClock } from "./use-visible-second-clock"; * model is working, for how long, and leads back to the parent. */ export function ProviderSubagentBar(props: { - /** Driver and catalog icon of the provider running the subagent. */ - readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null; + /** Driver, catalog icon and account badge of the provider running the subagent. */ + readonly provider: { + readonly driver: string; + readonly iconUrl?: string | undefined; + readonly badge?: ProviderBadge | undefined; + } | null; readonly modelLabel: string; /** Reasoning effort as the composer names it, when the subagent has one. */ readonly effortLabel: string | null; @@ -36,15 +41,18 @@ export function ProviderSubagentBar(props: { {/* Only the text is one element, so "Open parent" stays reachable. */} {props.provider ? ( - ) : null} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6873926fd5dd..fddc878be447 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -80,7 +80,7 @@ import { ComposerInlineControl, ComposerToolbarRow, } from "../../components/ComposerToolbar"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderAccountIcon } from "../../components/ProviderIcon"; import { composerStripAttachments, type DraftComposerAttachment, @@ -379,7 +379,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { themeVariables: materialTheme } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); - const foregroundColor = useUniwindTheme()["--color-foreground"]; + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; + const composerSurfaceColor = theme["--color-composer-surface"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; @@ -1124,13 +1126,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ( - )} label={currentModelOption?.label ?? currentModelSelection.model} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b6930deb5e86..e6bef5702829 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -91,6 +91,7 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; +import { resolveProviderBadge } from "../../lib/modelOptions"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; @@ -1348,7 +1349,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }} > void; }) { + const sheetColor = useUniwindTheme()["--color-sheet-solid"]; const content = ( <> - + {props.label} {props.collapsible ? ( <> @@ -545,6 +553,7 @@ type ThreadSettingsProviderCatalog = { readonly key: string; readonly driver: string | undefined; readonly iconUrl: string | undefined; + readonly badge: ProviderBadge | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; @@ -614,6 +623,7 @@ function ThreadSettingsProviderListHeader(props: { collapsed={props.provider.collapsed} driver={props.provider.driver} iconUrl={props.provider.iconUrl} + badge={props.provider.badge} label={props.provider.label} modelCount={props.provider.modelCount} onToggle={onToggle} @@ -676,6 +686,7 @@ function useThreadSettingsCatalogItems( key: group.providerKey, driver, iconUrl: group.models[0]?.providerIconUrl, + badge: group.models[0]?.providerBadge, label: group.providerLabel, collapsible, collapsed, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 463ff5cc3662..edc268c249dc 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -54,6 +54,68 @@ describe("mobile model options", () => { ]); }); + it("badges the account when two instances share a provider or one has an accent", () => { + const claude = (instanceId: string, extra: Record = {}) => ({ + instanceId, + driver: "claudeAgent", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { slug: "claude-opus-5-5", name: "Claude Opus 5.5", isCustom: false, capabilities: null }, + ], + ...extra, + }); + const badges = (providers: ReadonlyArray) => + buildModelOptions({ providers } as unknown as ServerConfig, null).map( + (option) => option.providerBadge, + ); + + expect(badges([claude("claudeAgent")])).toEqual([undefined]); + expect( + badges([claude("claudeAgent", { displayName: "Work", accentColor: "#eae10c" })]), + ).toEqual([{ displayName: "Work", accentColor: "#eae10c" }]); + expect( + badges([ + claude("claudeAgent", { displayName: "Personal" }), + claude("claude_work", { displayName: "Work" }), + ]), + ).toEqual([{ displayName: "Personal" }, { displayName: "Work" }]); + + // Different ACP agents have their own glyphs, so they need no badge. + const acp = (instanceId: string) => ({ + ...claude(instanceId), + driver: "acpRegistry", + }); + const acpConfig = { + providers: [acp("acp_a"), acp("acp_b")], + settings: { + providerInstances: { + acp_a: { driver: "acpRegistry", config: { agentId: "agent-a" } }, + acp_b: { driver: "acpRegistry", config: { agentId: "agent-b" } }, + }, + }, + } as unknown as ServerConfig; + expect(buildModelOptions(acpConfig, null).map((option) => option.providerBadge)).toEqual([ + undefined, + undefined, + ]); + + // Local ACP commands have no registry agent, so two of them share a glyph. + const localConfig = { + ...acpConfig, + settings: { + providerInstances: { + acp_a: { driver: "acpRegistry", config: { source: "local", agentId: "agent-a" } }, + acp_b: { driver: "acpRegistry", config: { source: "local", agentId: "agent-b" } }, + }, + }, + } as unknown as ServerConfig; + expect( + buildModelOptions(localConfig, null).every((option) => option.providerBadge !== undefined), + ).toBe(true); + }); + it("carries configured ACP identity into model and provider catalogs", () => { const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg"; const config = { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4469becee1a2..1dd179e2447a 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -9,6 +9,11 @@ import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; export type ModelOption = { readonly key: string; @@ -19,6 +24,8 @@ export type ModelOption = { readonly providerDriver: string; readonly supportedRuntimeModes?: ReadonlyArray; readonly providerIconUrl?: string | undefined; + /** Set when the instance needs the account badge. */ + readonly providerBadge?: ProviderBadge; readonly isDefault: boolean; readonly isLegacy: boolean; readonly isUnavailable?: boolean; @@ -26,6 +33,46 @@ export type ModelOption = { readonly selection: ModelSelection; }; +/** The account badge an instance's glyph carries. */ +export type ProviderBadge = { readonly displayName: string; readonly accentColor?: string }; + +type ServerProvider = T3ServerConfig["providers"][number]; + +/** + * The badge an instance needs by the shared rule: an accent colour, or another + * instance of the same provider. Settings carry each ACP instance's agent, + * whose own glyph already tells it apart. + */ +export function resolveProviderBadge( + config: T3ServerConfig | null | undefined, + provider: ServerProvider, +): ProviderBadge | undefined { + const badgeEntry = (candidate: ServerProvider) => { + const settings = config?.settings?.providerInstances[candidate.instanceId]?.config; + // A local ACP command has no registry agent, as on web. + const agentId = + typeof settings === "object" && settings && Reflect.get(settings, "source") !== "local" + ? Reflect.get(settings, "agentId") + : null; + return { + driverKind: candidate.driver, + ...(typeof agentId === "string" && agentId.trim() + ? { acpRegistryAgentId: agentId.trim() } + : {}), + }; + }; + const accentColor = normalizeProviderAccentColor(provider.accentColor); + return shouldShowInstanceBadge( + { ...badgeEntry(provider), accentColor }, + (config?.providers ?? []).map(badgeEntry), + ) + ? { + displayName: resolveProviderInstanceDisplayName(provider), + ...(accentColor ? { accentColor } : {}), + } + : undefined; +} + export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; @@ -174,6 +221,7 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); + const providerBadge = resolveProviderBadge(config, provider); for (const model of provider.models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { @@ -187,6 +235,7 @@ export function buildModelOptions( ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), + ...(providerBadge ? { providerBadge } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, @@ -230,6 +279,7 @@ export function buildModelOptions( displayName: provider?.displayName ?? instanceConfig?.displayName, instanceId: fallbackModelSelection.instanceId, }); + const providerBadge = provider ? resolveProviderBadge(config, provider) : undefined; options.set(key, { key, label: model?.name ?? fallbackModelSelection.model, @@ -237,6 +287,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver, + ...(providerBadge ? { providerBadge } : {}), isDefault: false, isLegacy: model?.isLegacy === true, ...(isModelSelectionUnavailable(config, fallbackModelSelection)