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)