diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 8ad497c9a79b..374738d0aeca 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -1,6 +1,9 @@
import { Image } from "expo-image";
import { Path, Svg } from "react-native-svg";
+import { View } from "react-native";
+import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { AppText as Text } from "./AppText";
type ProviderIconProps = {
readonly provider: string | null | undefined;
@@ -80,3 +83,58 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}
+
+/**
+ * `ProviderIcon` plus the web sidebar's account badge: an accent-color
+ * initials bubble in the bottom-right corner, drawn when `showBadge` is set
+ * (accent color present, or several instances share this driver). The glyph
+ * dims to 60% opacity while the badge stays fully saturated, matching
+ * `apps/web/src/components/chat/ProviderInstanceIcon.tsx`.
+ */
+export function ProviderInstanceIcon(props: {
+ readonly provider: string | null | undefined;
+ readonly size?: number;
+ readonly displayName: string;
+ readonly accentColor?: string;
+ readonly showBadge?: boolean;
+ readonly surfaceColor: string;
+}) {
+ return (
+
+
+
+
+ {props.showBadge ? (
+
+
+ {providerInstanceInitials(props.displayName)}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index e628eea08e31..6be34cc17f83 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -51,6 +51,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2SnoozedShelfHeader,
} from "../threads/thread-list-v2-items";
+import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
@@ -840,15 +841,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectTitle={v2ProjectTitleByProjectKey.get(
scopedProjectKey(thread.environmentId, thread.projectId),
)}
- providerDriver={
- serverConfigs
- .get(thread.environmentId)
- ?.providers.find(
- (provider) =>
- provider.instanceId ===
- (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
- )?.driver ?? null
- }
+ providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
environmentLabel={
Object.keys(props.savedConnectionsById).length > 1
? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index d3cd65fe8a7b..a7ce89927f43 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -78,6 +78,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2SnoozedShelfHeader,
} from "./thread-list-v2-items";
+import { resolveThreadProviderInstance } from "./thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
@@ -901,15 +902,7 @@ function ThreadNavigationSidebarPane(
snoozeWakeLabelText={item.snoozeWakeLabelText}
project={projectByKey.get(scopeKey) ?? null}
projectTitle={projectTitleByProjectKey.get(scopeKey)}
- providerDriver={
- serverConfigs
- .get(thread.environmentId)
- ?.providers.find(
- (provider) =>
- provider.instanceId ===
- (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
- )?.driver ?? null
- }
+ providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
environmentLabel={
Object.keys(savedConnectionsById).length > 1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index c2e66ece53de..73c6084cff61 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -16,7 +16,8 @@ import { AppText as Text } from "../../components/AppText";
import { ControlPillMenu } from "../../components/ControlPill";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ProjectFavicon } from "../../components/ProjectFavicon";
-import { ProviderIcon } from "../../components/ProviderIcon";
+import { ProviderInstanceIcon } from "../../components/ProviderIcon";
+import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import { cn } from "../../lib/cn";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
@@ -349,7 +350,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly snoozePresetMinute: string;
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
- readonly providerDriver: string | null;
+ readonly providerInstance: ThreadRowProviderInstance | null;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
the web sidebar's remote-environment cloud icon, but as text since
@@ -435,6 +436,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const selectedBackgroundColor = theme["--color-user-bubble"];
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;
+ // The provider badge's border blends into the row's own surface, which
+ // differs by pane and (for the sidebar pane) selection: the sidebar row
+ // background becomes the selected fill or the drawer surface, while the
+ // flat "screen" pane rows always sit on the screen background.
+ const providerIconSurfaceColor = sidebarPane
+ ? selected
+ ? selectedBackgroundColor
+ : drawerColor
+ : screenColor;
const status = resolveThreadListV2Status(thread);
const statusLabel = STATUS_LABEL_BY_STATUS[status];
@@ -825,10 +835,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
#{pr.label}
) : null}
- {props.providerDriver ? (
-
-
-
+ {props.providerInstance ? (
+
) : null}
>
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
new file mode 100644
index 000000000000..2afef9759070
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
@@ -0,0 +1,98 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import {
+ EnvironmentId,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+ type ServerConfig,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveThreadProviderInstance } from "./thread-provider-instance";
+
+function makeConfig(
+ providers: ReadonlyArray<{
+ readonly instanceId: string;
+ readonly driver: string;
+ readonly displayName?: string;
+ readonly accentColor?: string;
+ }>,
+): ServerConfig {
+ return { providers } as unknown as ServerConfig;
+}
+
+function makeThread(environmentId: EnvironmentId, instanceId: string): EnvironmentThreadShell {
+ return {
+ environmentId,
+ id: ThreadId.make("thread-1"),
+ projectId: ProjectId.make("project-1"),
+ title: "Thread",
+ modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: "2026-06-01T00:00:00.000Z",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ session: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ } as unknown as EnvironmentThreadShell;
+}
+
+describe("resolveThreadProviderInstance", () => {
+ it("resolves two environments with the same default instance id independently", () => {
+ const environmentA = EnvironmentId.make("environment-a");
+ const environmentB = EnvironmentId.make("environment-b");
+ const serverConfigs = new Map([
+ [
+ environmentA,
+ makeConfig([{ instanceId: "codex", driver: "codex", accentColor: "#ff8800" }]),
+ ],
+ [environmentB, makeConfig([{ instanceId: "codex", driver: "codex" }])],
+ ]);
+
+ const threadA = makeThread(environmentA, "codex");
+ const threadB = makeThread(environmentB, "codex");
+
+ expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800");
+ expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined();
+ });
+
+ it("labels a custom instance by its id so its initials differ from the default", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const serverConfigs = new Map([
+ [
+ environmentId,
+ makeConfig([
+ { instanceId: "codex", driver: "codex", displayName: "Codex" },
+ { instanceId: "codex_personal", driver: "codex", displayName: "Codex" },
+ ]),
+ ],
+ ]);
+
+ expect(
+ resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName,
+ ).toBe("Codex");
+ expect(
+ resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal"))
+ ?.displayName,
+ ).toBe("Codex Personal");
+ });
+
+ it("hides the badge for a single instance with no accent color", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const serverConfigs = new Map([
+ [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])],
+ ]);
+ const thread = makeThread(environmentId, "codex");
+
+ expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts
new file mode 100644
index 000000000000..29dbe8828b66
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-provider-instance.ts
@@ -0,0 +1,42 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import {
+ normalizeProviderAccentColor,
+ resolveProviderInstanceDisplayName,
+ shouldShowInstanceBadge,
+} from "@t3tools/client-runtime/state/provider-instance-display";
+import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
+
+/** What a thread row needs to draw the provider glyph and its account badge. */
+export interface ThreadRowProviderInstance {
+ readonly driverKind: ProviderDriverKind;
+ readonly displayName: string;
+ readonly accentColor?: string | undefined;
+ readonly showBadge: boolean;
+}
+
+/**
+ * Resolve the provider instance a thread runs on, scoped to the thread's own
+ * environment: default instance ids are the driver slug, so the same id
+ * names a different account on every server.
+ */
+export function resolveThreadProviderInstance(
+ serverConfigs: ReadonlyMap,
+ thread: EnvironmentThreadShell,
+): ThreadRowProviderInstance | null {
+ const providers = serverConfigs.get(thread.environmentId)?.providers ?? [];
+ const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ const snapshot = providers.find((provider) => provider.instanceId === instanceId);
+ if (!snapshot) return null;
+ const entry = {
+ driverKind: snapshot.driver,
+ displayName: resolveProviderInstanceDisplayName(snapshot),
+ accentColor: normalizeProviderAccentColor(snapshot.accentColor),
+ };
+ return {
+ ...entry,
+ showBadge: shouldShowInstanceBadge(
+ entry,
+ providers.map((provider) => ({ driverKind: provider.driver })),
+ ),
+ };
+}
diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx
index 53c66667f939..4a40ed15bcb3 100644
--- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx
+++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx
@@ -1,18 +1,11 @@
import { type CSSProperties, memo } from "react";
import { type ProviderDriverKind } from "@t3tools/contracts";
+import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
import { cn } from "~/lib/utils";
-export function providerInstanceInitials(label: string): string {
- const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
- if (words.length === 0) return "";
- if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();
- return words
- .slice(0, 2)
- .map((word) => word[0]?.toUpperCase() ?? "")
- .join("");
-}
+export { providerInstanceInitials };
export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
driverKind: ProviderDriverKind;
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index fe95cf54890a..9230ab0a489e 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -15,7 +15,6 @@
import {
DEFAULT_MODEL_BY_PROVIDER,
defaultInstanceIdForDriver,
- PROVIDER_DISPLAY_NAMES,
resolveProviderInstanceEnabled,
type ModelSelection,
type ProviderDriverKind,
@@ -25,8 +24,13 @@ import {
type ServerSettings,
type ServerProviderState,
} from "@t3tools/contracts";
+import {
+ normalizeProviderAccentColor,
+ resolveProviderInstanceDisplayName,
+ shouldShowInstanceBadge,
+} from "@t3tools/client-runtime/state/provider-instance-display";
-import { formatProviderDriverKindLabel } from "./providerModels";
+export { normalizeProviderAccentColor, shouldShowInstanceBadge };
/**
* Local-only placeholder used while a draft has no provider it can safely
@@ -80,93 +84,6 @@ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): b
return entry.enabled;
}
-/**
- * Turn an instance id slug into a human-readable label. Splits on `_` / `-`
- * and camelCase boundaries and title-cases each token, so `codex_personal`
- * becomes "Codex Personal" and `myCustomInstance` becomes "My Custom
- * Instance".
- *
- * This is a fallback used only when the wire snapshot's `displayName`
- * doesn't disambiguate a non-default instance from the default one of the
- * same driver (today every built-in driver hard-codes a single presentation
- * label per kind, so two instances of the same kind arrive with identical
- * display names). When a server/driver later plumbs the user's configured
- * `ProviderInstanceConfig.displayName` through to the snapshot, that value
- * will take precedence over this fallback.
- */
-function humanizeInstanceId(instanceId: ProviderInstanceId): string {
- const words: string[] = [];
- for (const token of instanceId
- .replace(/[_-]+/g, " ")
- .replace(/([a-z])([A-Z])/g, "$1 $2")
- .split(" ")) {
- if (token.length === 0) continue;
- words.push(token.charAt(0).toUpperCase() + token.slice(1));
- }
- return words.join(" ");
-}
-
-function driverKindLabel(driverKind: ProviderDriverKind): string {
- return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind);
-}
-
-/**
- * Whether an instance's icon carries the account badge: accent color set, or
- * several instances sharing a driver so the brand glyph alone is ambiguous.
- * Shared by the composer trigger, the picker rail, and sidebar rows.
- */
-export function shouldShowInstanceBadge(
- entry: ProviderInstanceEntry,
- entries: Iterable,
-): boolean {
- if (entry.accentColor) return true;
- let sharedDriverCount = 0;
- for (const candidate of entries) {
- if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true;
- }
- return false;
-}
-
-export function normalizeProviderAccentColor(value: string | undefined): string | undefined {
- const trimmed = value?.trim();
- if (!trimmed) return undefined;
- return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
-}
-
-/**
- * Resolve an entry's displayName with a tiered priority:
- *
- * 1. A snapshot `displayName` that differs from the driver-kind label —
- * the server has explicitly named this instance, trust it.
- * 2. For non-default instances, a humanized `instanceId` — the server
- * fell back to the driver-level presentation constant (which is the
- * same for every instance of that kind), so we differentiate at the
- * UI layer by slug. This is what keeps "Codex" + "Codex Personal"
- * distinguishable in tooltips and list labels today.
- * 3. The snapshot's `displayName` (if any) — default instance, trust
- * whatever label the driver stamped.
- * 4. `driverKindLabel(driverKind)` — nothing else on hand, so use the
- * canonical brand label from contracts (falling back to a generic
- * title-case of the kind slug).
- */
-function resolveInstanceDisplayName(
- snapshot: ServerProvider,
- instanceId: ProviderInstanceId,
- driverKind: ProviderDriverKind,
- isDefault: boolean,
-): string {
- const trimmedSnapshotName = snapshot.displayName?.trim();
- const kindLabel = driverKindLabel(driverKind);
- if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) {
- return trimmedSnapshotName;
- }
- if (!isDefault) {
- const humanized = humanizeInstanceId(instanceId);
- if (humanized.length > 0) return humanized;
- }
- return trimmedSnapshotName || kindLabel;
-}
-
/**
* Project the wire `ServerProvider[]` into instance entries, one per
* configured instance. Preserves the server's ordering (which sources
@@ -182,11 +99,10 @@ export function deriveProviderInstanceEntries(
const driverKind = snapshot.driver;
const defaultId = defaultInstanceIdForDriver(driverKind);
const isDefault = instanceId === defaultId;
- const displayName = resolveInstanceDisplayName(snapshot, instanceId, driverKind, isDefault);
return {
instanceId,
driverKind,
- displayName,
+ displayName: resolveProviderInstanceDisplayName(snapshot),
accentColor: normalizeProviderAccentColor(snapshot.accentColor),
continuationGroupKey: snapshot.continuation?.groupKey,
enabled: snapshot.enabled,
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index fdab38c9cc62..dbd2688a72a7 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -139,6 +139,10 @@
"types": "./src/state/projects.ts",
"default": "./src/state/projects.ts"
},
+ "./state/provider-instance-display": {
+ "types": "./src/state/providerInstanceDisplay.ts",
+ "default": "./src/state/providerInstanceDisplay.ts"
+ },
"./state/pull-requests": {
"types": "./src/state/pullRequests.ts",
"default": "./src/state/pullRequests.ts"
diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts
new file mode 100644
index 000000000000..732cd8c0506b
--- /dev/null
+++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts
@@ -0,0 +1,108 @@
+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ normalizeProviderAccentColor,
+ providerInstanceInitials,
+ resolveProviderInstanceDisplayName,
+ shouldShowInstanceBadge,
+} from "./providerInstanceDisplay.ts";
+
+const codex = ProviderDriverKind.make("codex");
+const claude = ProviderDriverKind.make("claudeAgent");
+
+describe("resolveProviderInstanceDisplayName", () => {
+ it("keeps a snapshot name that differs from the brand label", () => {
+ expect(
+ resolveProviderInstanceDisplayName({
+ instanceId: ProviderInstanceId.make("codex"),
+ driver: codex,
+ displayName: "Work",
+ }),
+ ).toBe("Work");
+ });
+
+ it("humanizes a custom instance id when the snapshot only carries the brand label", () => {
+ expect(
+ resolveProviderInstanceDisplayName({
+ instanceId: ProviderInstanceId.make("codex_personal"),
+ driver: codex,
+ displayName: "Codex",
+ }),
+ ).toBe("Codex Personal");
+ });
+
+ it("uses the brand label for the default instance", () => {
+ expect(
+ resolveProviderInstanceDisplayName({
+ instanceId: ProviderInstanceId.make("codex"),
+ driver: codex,
+ }),
+ ).toBe("Codex");
+ });
+});
+
+describe("providerInstanceInitials", () => {
+ it("takes the first two characters of a single word", () => {
+ expect(providerInstanceInitials("Codex")).toBe("CO");
+ });
+
+ it("takes the first character of each of the first two words", () => {
+ expect(providerInstanceInitials("Codex Personal")).toBe("CP");
+ });
+
+ it("ignores words past the first two", () => {
+ expect(providerInstanceInitials("Codex Personal Backup Account")).toBe("CP");
+ });
+
+ it("returns an empty string for an empty label", () => {
+ expect(providerInstanceInitials("")).toBe("");
+ });
+
+ it("keeps an emoji whole instead of splitting its surrogate pair", () => {
+ expect(providerInstanceInitials("😀 Work")).toBe("😀W");
+ expect(providerInstanceInitials("😀")).toBe("😀");
+ });
+});
+
+describe("normalizeProviderAccentColor", () => {
+ it("accepts a lowercase hex color", () => {
+ expect(normalizeProviderAccentColor("#ff8800")).toBe("#ff8800");
+ });
+
+ it("accepts an uppercase hex color", () => {
+ expect(normalizeProviderAccentColor("#FF8800")).toBe("#FF8800");
+ });
+
+ it("rejects a non-hex value", () => {
+ expect(normalizeProviderAccentColor("blue")).toBeUndefined();
+ });
+
+ it("rejects a short hex value", () => {
+ expect(normalizeProviderAccentColor("#fff")).toBeUndefined();
+ });
+
+ it("treats undefined and blank as unset", () => {
+ expect(normalizeProviderAccentColor(undefined)).toBeUndefined();
+ expect(normalizeProviderAccentColor(" ")).toBeUndefined();
+ });
+});
+
+describe("shouldShowInstanceBadge", () => {
+ it("shows the badge when the entry has an accent color", () => {
+ const entry = { driverKind: codex, accentColor: "#ff8800" };
+ expect(shouldShowInstanceBadge(entry, [entry])).toBe(true);
+ });
+
+ it("shows the badge when two entries share a driver, even without an accent", () => {
+ const first = { driverKind: codex, accentColor: undefined };
+ const second = { driverKind: codex, accentColor: undefined };
+ expect(shouldShowInstanceBadge(first, [first, second])).toBe(true);
+ });
+
+ it("hides the badge for a single instance of a driver with no accent", () => {
+ const entry = { driverKind: codex, accentColor: undefined };
+ const other = { driverKind: claude, accentColor: undefined };
+ expect(shouldShowInstanceBadge(entry, [entry, other])).toBe(false);
+ });
+});
diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts
new file mode 100644
index 000000000000..6d7a0629b13a
--- /dev/null
+++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts
@@ -0,0 +1,88 @@
+/**
+ * How a configured provider instance presents itself in a client: its label,
+ * its accent color, and whether its icon carries the account badge. Shared by
+ * web and mobile so both clients name and badge the same instance identically.
+ *
+ * @module providerInstanceDisplay
+ */
+import {
+ defaultInstanceIdForDriver,
+ PROVIDER_DISPLAY_NAMES,
+ type ProviderDriverKind,
+ type ServerProvider,
+} from "@t3tools/contracts";
+
+/**
+ * Title-case a slug: splits on `_` / `-` and camelCase boundaries, so
+ * `codex_personal` becomes "Codex Personal" and `myCustomInstance` becomes
+ * "My Custom Instance".
+ */
+function humanizeSlug(slug: string): string {
+ return slug
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
+ .replace(/[_-]+/g, " ")
+ .trim()
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+/**
+ * Resolve an instance's label with a tiered priority:
+ *
+ * 1. A snapshot `displayName` that differs from the driver's brand label —
+ * the server has explicitly named this instance, trust it.
+ * 2. For non-default instances, a humanized `instanceId` — the server fell
+ * back to the driver-level label (the same for every instance of that
+ * kind), so the slug is what keeps "Codex" and "Codex Personal" apart.
+ * 3. The snapshot's `displayName`, or the brand label from contracts.
+ */
+export function resolveProviderInstanceDisplayName(
+ snapshot: Pick,
+): string {
+ const trimmedSnapshotName = snapshot.displayName?.trim();
+ const kindLabel = PROVIDER_DISPLAY_NAMES[snapshot.driver] ?? humanizeSlug(snapshot.driver);
+ if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) return trimmedSnapshotName;
+ if (snapshot.instanceId !== defaultInstanceIdForDriver(snapshot.driver)) {
+ const humanized = humanizeSlug(snapshot.instanceId);
+ if (humanized.length > 0) return humanized;
+ }
+ return trimmedSnapshotName || kindLabel;
+}
+
+/**
+ * Turn a display name into up to two initials for the badge: the first two
+ * characters of a single word, or the first character of each of the first
+ * two words. Iterates by code point so an emoji never splits into surrogates.
+ */
+export function providerInstanceInitials(label: string): string {
+ const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
+ if (words.length === 0) return "";
+ if (words.length === 1) return Array.from(words[0]!).slice(0, 2).join("").toUpperCase();
+ return words
+ .slice(0, 2)
+ .map((word) => Array.from(word)[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+/** Only `#rrggbb` accent colors render; anything else is treated as unset. */
+export function normalizeProviderAccentColor(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ if (!trimmed) return undefined;
+ return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
+}
+
+/**
+ * Whether an instance's icon carries the account badge: accent color set, or
+ * several instances sharing a driver so the brand glyph alone is ambiguous.
+ * Shared by the composer trigger, the picker rail, and sidebar/thread rows.
+ */
+export function shouldShowInstanceBadge(
+ entry: { readonly driverKind: ProviderDriverKind; readonly accentColor?: string | undefined },
+ entries: Iterable<{ readonly driverKind: ProviderDriverKind }>,
+): boolean {
+ if (entry.accentColor) return true;
+ let sharedDriverCount = 0;
+ for (const candidate of entries) {
+ if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true;
+ }
+ return false;
+}