authorgravatar for vitalyiegorov@gmail.comVitaly Iegorov <vitalyiegorov@gmail.com> 2026-09-08 01:02:40+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 16:02:40-07:00
log2c8e95a4b641898322c7312cb67535612606745e
tree7ab26456d0684b1f4df488d1aafd435fdf48e79b
parent09e8de9c655ae85410bf6b00446f272a01da81c7
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show the provider account badge on thread rows (#9899)


11 files changed, 432 insertions(+), 124 deletions(-)

apps/mobile/src/components/ProviderIcon.tsx+58
......@@ -1,6 +1,9 @@
11import { Image } from "expo-image";
22import { Path, Svg } from "react-native-svg";
3import { View } from "react-native";
4import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
35import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { AppText as Text } from "./AppText";
47
58type ProviderIconProps = {
69 readonly provider: string | null | undefined;
......@@ -80,3 +83,58 @@ export function ProviderIcon(props: ProviderIconProps) {
8083 </Svg>
8184 );
8285}
86
87/**
88 * `ProviderIcon` plus the web sidebar's account badge: an accent-color
89 * initials bubble in the bottom-right corner, drawn when `showBadge` is set
90 * (accent color present, or several instances share this driver). The glyph
91 * dims to 60% opacity while the badge stays fully saturated, matching
92 * `apps/web/src/components/chat/ProviderInstanceIcon.tsx`.
93 */
94export function ProviderInstanceIcon(props: {
95 readonly provider: string | null | undefined;
96 readonly size?: number;
97 readonly displayName: string;
98 readonly accentColor?: string;
99 readonly showBadge?: boolean;
100 readonly surfaceColor: string;
101}) {
102 return (
103 <View style={{ position: "relative" }}>
104 <View style={{ opacity: 0.6 }}>
105 <ProviderIcon provider={props.provider} size={props.size} />
106 </View>
107 {props.showBadge ? (
108 <View
109 className={props.accentColor ? undefined : "bg-card"}
110 style={{
111 position: "absolute",
112 right: -3,
113 bottom: -3,
114 height: 12,
115 minWidth: 12,
116 paddingHorizontal: 2,
117 borderRadius: 999,
118 borderWidth: 1,
119 borderColor: props.surfaceColor,
120 backgroundColor: props.accentColor,
121 alignItems: "center",
122 justifyContent: "center",
123 }}
124 >
125 <Text
126 className={props.accentColor ? undefined : "text-foreground-muted"}
127 style={{
128 fontSize: 7,
129 fontWeight: "600",
130 lineHeight: 9,
131 color: props.accentColor ? "#ffffff" : undefined,
132 }}
133 >
134 {providerInstanceInitials(props.displayName)}
135 </Text>
136 </View>
137 ) : null}
138 </View>
139 );
140}
apps/mobile/src/features/home/HomeScreen.tsx+2-9
......@@ -51,6 +51,7 @@ import {
5151 ThreadListV2SettledShelfHeader,
5252 ThreadListV2SnoozedShelfHeader,
5353} from "../threads/thread-list-v2-items";
54import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
5455import {
5556 buildThreadListV2Items,
5657 getThreadListV2OrderedSection,
......@@ -840,15 +841,7 @@ export function HomeScreen(props: HomeScreenProps) {
840841 projectTitle={v2ProjectTitleByProjectKey.get(
841842 scopedProjectKey(thread.environmentId, thread.projectId),
842843 )}
843 providerDriver={
844 serverConfigs
845 .get(thread.environmentId)
846 ?.providers.find(
847 (provider) =>
848 provider.instanceId ===
849 (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
850 )?.driver ?? null
851 }
844 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
852845 environmentLabel={
853846 Object.keys(props.savedConnectionsById).length > 1
854847 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2-9
......@@ -78,6 +78,7 @@ import {
7878 ThreadListV2SettledShelfHeader,
7979 ThreadListV2SnoozedShelfHeader,
8080} from "./thread-list-v2-items";
81import { resolveThreadProviderInstance } from "./thread-provider-instance";
8182import {
8283 buildThreadListV2Items,
8384 getThreadListV2OrderedSection,
......@@ -901,15 +902,7 @@ function ThreadNavigationSidebarPane(
901902 snoozeWakeLabelText={item.snoozeWakeLabelText}
902903 project={projectByKey.get(scopeKey) ?? null}
903904 projectTitle={projectTitleByProjectKey.get(scopeKey)}
904 providerDriver={
905 serverConfigs
906 .get(thread.environmentId)
907 ?.providers.find(
908 (provider) =>
909 provider.instanceId ===
910 (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
911 )?.driver ?? null
912 }
905 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
913906 environmentLabel={
914907 Object.keys(savedConnectionsById).length > 1
915908 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
apps/mobile/src/features/threads/thread-list-v2-items.tsx+21-6
......@@ -16,7 +16,8 @@ import { AppText as Text } from "../../components/AppText";
1616import { ControlPillMenu } from "../../components/ControlPill";
1717import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1818import { ProjectFavicon } from "../../components/ProjectFavicon";
19import { ProviderIcon } from "../../components/ProviderIcon";
19import { ProviderInstanceIcon } from "../../components/ProviderIcon";
20import type { ThreadRowProviderInstance } from "./thread-provider-instance";
2021import { cn } from "../../lib/cn";
2122import { relativeTime } from "../../lib/time";
2223import { useUniwindTheme } from "../../lib/useUniwindTheme";
......@@ -349,7 +350,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
349350 readonly snoozePresetMinute: string;
350351 readonly project: EnvironmentProject | null;
351352 readonly projectTitle?: string;
352 readonly providerDriver: string | null;
353 readonly providerInstance: ThreadRowProviderInstance | null;
353354 /** Which machine hosts the thread. Null when only one environment is
354355 connected — repeating the same label on every row is noise. Mirrors
355356 the web sidebar's remote-environment cloud icon, but as text since
......@@ -435,6 +436,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
435436 const selectedBackgroundColor = theme["--color-user-bubble"];
436437 const sidebarPane = props.pane === "sidebar";
437438 const selected = props.selected === true;
439 // The provider badge's border blends into the row's own surface, which
440 // differs by pane and (for the sidebar pane) selection: the sidebar row
441 // background becomes the selected fill or the drawer surface, while the
442 // flat "screen" pane rows always sit on the screen background.
443 const providerIconSurfaceColor = sidebarPane
444 ? selected
445 ? selectedBackgroundColor
446 : drawerColor
447 : screenColor;
438448
439449 const status = resolveThreadListV2Status(thread);
440450 const statusLabel = STATUS_LABEL_BY_STATUS[status];
......@@ -825,10 +835,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
825835 #{pr.label}
826836 </Text>
827837 ) : null}
828 {props.providerDriver ? (
829 <View className="opacity-60">
830 <ProviderIcon provider={props.providerDriver} size={14} />
831 </View>
838 {props.providerInstance ? (
839 <ProviderInstanceIcon
840 provider={props.providerInstance.driverKind}
841 size={14}
842 displayName={props.providerInstance.displayName}
843 accentColor={props.providerInstance.accentColor}
844 showBadge={props.providerInstance.showBadge}
845 surfaceColor={providerIconSurfaceColor}
846 />
832847 ) : null}
833848 </View>
834849 </>
apps/mobile/src/features/threads/thread-provider-instance.test.ts created+98
......@@ -0,0 +1,98 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type ServerConfig,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10
11import { resolveThreadProviderInstance } from "./thread-provider-instance";
12
13function makeConfig(
14 providers: ReadonlyArray<{
15 readonly instanceId: string;
16 readonly driver: string;
17 readonly displayName?: string;
18 readonly accentColor?: string;
19 }>,
20): ServerConfig {
21 return { providers } as unknown as ServerConfig;
22}
23
24function makeThread(environmentId: EnvironmentId, instanceId: string): EnvironmentThreadShell {
25 return {
26 environmentId,
27 id: ThreadId.make("thread-1"),
28 projectId: ProjectId.make("project-1"),
29 title: "Thread",
30 modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model: "gpt-5.4" },
31 runtimeMode: "full-access",
32 interactionMode: "default",
33 branch: null,
34 worktreePath: null,
35 latestTurn: null,
36 createdAt: "2026-06-01T00:00:00.000Z",
37 updatedAt: "2026-06-01T00:00:00.000Z",
38 archivedAt: null,
39 settledOverride: null,
40 settledAt: null,
41 session: null,
42 latestUserMessageAt: null,
43 hasPendingApprovals: false,
44 hasPendingUserInput: false,
45 hasActionableProposedPlan: false,
46 } as unknown as EnvironmentThreadShell;
47}
48
49describe("resolveThreadProviderInstance", () => {
50 it("resolves two environments with the same default instance id independently", () => {
51 const environmentA = EnvironmentId.make("environment-a");
52 const environmentB = EnvironmentId.make("environment-b");
53 const serverConfigs = new Map<EnvironmentId, ServerConfig>([
54 [
55 environmentA,
56 makeConfig([{ instanceId: "codex", driver: "codex", accentColor: "#ff8800" }]),
57 ],
58 [environmentB, makeConfig([{ instanceId: "codex", driver: "codex" }])],
59 ]);
60
61 const threadA = makeThread(environmentA, "codex");
62 const threadB = makeThread(environmentB, "codex");
63
64 expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800");
65 expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined();
66 });
67
68 it("labels a custom instance by its id so its initials differ from the default", () => {
69 const environmentId = EnvironmentId.make("environment-a");
70 const serverConfigs = new Map<EnvironmentId, ServerConfig>([
71 [
72 environmentId,
73 makeConfig([
74 { instanceId: "codex", driver: "codex", displayName: "Codex" },
75 { instanceId: "codex_personal", driver: "codex", displayName: "Codex" },
76 ]),
77 ],
78 ]);
79
80 expect(
81 resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName,
82 ).toBe("Codex");
83 expect(
84 resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal"))
85 ?.displayName,
86 ).toBe("Codex Personal");
87 });
88
89 it("hides the badge for a single instance with no accent color", () => {
90 const environmentId = EnvironmentId.make("environment-a");
91 const serverConfigs = new Map<EnvironmentId, ServerConfig>([
92 [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])],
93 ]);
94 const thread = makeThread(environmentId, "codex");
95
96 expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
97 });
98});
apps/mobile/src/features/threads/thread-provider-instance.ts created+42
......@@ -0,0 +1,42 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 normalizeProviderAccentColor,
4 resolveProviderInstanceDisplayName,
5 shouldShowInstanceBadge,
6} from "@t3tools/client-runtime/state/provider-instance-display";
7import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
8
9/** What a thread row needs to draw the provider glyph and its account badge. */
10export interface ThreadRowProviderInstance {
11 readonly driverKind: ProviderDriverKind;
12 readonly displayName: string;
13 readonly accentColor?: string | undefined;
14 readonly showBadge: boolean;
15}
16
17/**
18 * Resolve the provider instance a thread runs on, scoped to the thread's own
19 * environment: default instance ids are the driver slug, so the same id
20 * names a different account on every server.
21 */
22export function resolveThreadProviderInstance(
23 serverConfigs: ReadonlyMap<EnvironmentId, ServerConfig>,
24 thread: EnvironmentThreadShell,
25): ThreadRowProviderInstance | null {
26 const providers = serverConfigs.get(thread.environmentId)?.providers ?? [];
27 const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
28 const snapshot = providers.find((provider) => provider.instanceId === instanceId);
29 if (!snapshot) return null;
30 const entry = {
31 driverKind: snapshot.driver,
32 displayName: resolveProviderInstanceDisplayName(snapshot),
33 accentColor: normalizeProviderAccentColor(snapshot.accentColor),
34 };
35 return {
36 ...entry,
37 showBadge: shouldShowInstanceBadge(
38 entry,
39 providers.map((provider) => ({ driverKind: provider.driver })),
40 ),
41 };
42}
apps/web/src/components/chat/ProviderInstanceIcon.tsx+2-9
......@@ -1,18 +1,11 @@
11import { type CSSProperties, memo } from "react";
22import { type ProviderDriverKind } from "@t3tools/contracts";
3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
34
45import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
56import { cn } from "~/lib/utils";
67
7export function providerInstanceInitials(label: string): string {
8 const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
9 if (words.length === 0) return "";
10 if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();
11 return words
12 .slice(0, 2)
13 .map((word) => word[0]?.toUpperCase() ?? "")
14 .join("");
15}
8export { providerInstanceInitials };
169
1710export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
1811 driverKind: ProviderDriverKind;
apps/web/src/providerInstances.ts+7-91
......@@ -15,7 +15,6 @@
1515import {
1616 DEFAULT_MODEL_BY_PROVIDER,
1717 defaultInstanceIdForDriver,
18 PROVIDER_DISPLAY_NAMES,
1918 resolveProviderInstanceEnabled,
2019 type ModelSelection,
2120 type ProviderDriverKind,
......@@ -25,8 +24,13 @@ import {
2524 type ServerSettings,
2625 type ServerProviderState,
2726} from "@t3tools/contracts";
27import {
28 normalizeProviderAccentColor,
29 resolveProviderInstanceDisplayName,
30 shouldShowInstanceBadge,
31} from "@t3tools/client-runtime/state/provider-instance-display";
2832
29import { formatProviderDriverKindLabel } from "./providerModels";
33export { normalizeProviderAccentColor, shouldShowInstanceBadge };
3034
3135/**
3236 * Local-only placeholder used while a draft has no provider it can safely
......@@ -80,93 +84,6 @@ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): b
8084 return entry.enabled;
8185}
8286
83/**
84 * Turn an instance id slug into a human-readable label. Splits on `_` / `-`
85 * and camelCase boundaries and title-cases each token, so `codex_personal`
86 * becomes "Codex Personal" and `myCustomInstance` becomes "My Custom
87 * Instance".
88 *
89 * This is a fallback used only when the wire snapshot's `displayName`
90 * doesn't disambiguate a non-default instance from the default one of the
91 * same driver (today every built-in driver hard-codes a single presentation
92 * label per kind, so two instances of the same kind arrive with identical
93 * display names). When a server/driver later plumbs the user's configured
94 * `ProviderInstanceConfig.displayName` through to the snapshot, that value
95 * will take precedence over this fallback.
96 */
97function humanizeInstanceId(instanceId: ProviderInstanceId): string {
98 const words: string[] = [];
99 for (const token of instanceId
100 .replace(/[_-]+/g, " ")
101 .replace(/([a-z])([A-Z])/g, "$1 $2")
102 .split(" ")) {
103 if (token.length === 0) continue;
104 words.push(token.charAt(0).toUpperCase() + token.slice(1));
105 }
106 return words.join(" ");
107}
108
109function driverKindLabel(driverKind: ProviderDriverKind): string {
110 return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind);
111}
112
113/**
114 * Whether an instance's icon carries the account badge: accent color set, or
115 * several instances sharing a driver so the brand glyph alone is ambiguous.
116 * Shared by the composer trigger, the picker rail, and sidebar rows.
117 */
118export function shouldShowInstanceBadge(
119 entry: ProviderInstanceEntry,
120 entries: Iterable<ProviderInstanceEntry>,
121): boolean {
122 if (entry.accentColor) return true;
123 let sharedDriverCount = 0;
124 for (const candidate of entries) {
125 if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true;
126 }
127 return false;
128}
129
130export function normalizeProviderAccentColor(value: string | undefined): string | undefined {
131 const trimmed = value?.trim();
132 if (!trimmed) return undefined;
133 return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
134}
135
136/**
137 * Resolve an entry's displayName with a tiered priority:
138 *
139 * 1. A snapshot `displayName` that differs from the driver-kind label —
140 * the server has explicitly named this instance, trust it.
141 * 2. For non-default instances, a humanized `instanceId` — the server
142 * fell back to the driver-level presentation constant (which is the
143 * same for every instance of that kind), so we differentiate at the
144 * UI layer by slug. This is what keeps "Codex" + "Codex Personal"
145 * distinguishable in tooltips and list labels today.
146 * 3. The snapshot's `displayName` (if any) — default instance, trust
147 * whatever label the driver stamped.
148 * 4. `driverKindLabel(driverKind)` — nothing else on hand, so use the
149 * canonical brand label from contracts (falling back to a generic
150 * title-case of the kind slug).
151 */
152function resolveInstanceDisplayName(
153 snapshot: ServerProvider,
154 instanceId: ProviderInstanceId,
155 driverKind: ProviderDriverKind,
156 isDefault: boolean,
157): string {
158 const trimmedSnapshotName = snapshot.displayName?.trim();
159 const kindLabel = driverKindLabel(driverKind);
160 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) {
161 return trimmedSnapshotName;
162 }
163 if (!isDefault) {
164 const humanized = humanizeInstanceId(instanceId);
165 if (humanized.length > 0) return humanized;
166 }
167 return trimmedSnapshotName || kindLabel;
168}
169
17087/**
17188 * Project the wire `ServerProvider[]` into instance entries, one per
17289 * configured instance. Preserves the server's ordering (which sources
......@@ -182,11 +99,10 @@ export function deriveProviderInstanceEntries(
18299 const driverKind = snapshot.driver;
183100 const defaultId = defaultInstanceIdForDriver(driverKind);
184101 const isDefault = instanceId === defaultId;
185 const displayName = resolveInstanceDisplayName(snapshot, instanceId, driverKind, isDefault);
186102 return {
187103 instanceId,
188104 driverKind,
189 displayName,
105 displayName: resolveProviderInstanceDisplayName(snapshot),
190106 accentColor: normalizeProviderAccentColor(snapshot.accentColor),
191107 continuationGroupKey: snapshot.continuation?.groupKey,
192108 enabled: snapshot.enabled,
packages/client-runtime/package.json+4
......@@ -139,6 +139,10 @@
139139 "types": "./src/state/projects.ts",
140140 "default": "./src/state/projects.ts"
141141 },
142 "./state/provider-instance-display": {
143 "types": "./src/state/providerInstanceDisplay.ts",
144 "default": "./src/state/providerInstanceDisplay.ts"
145 },
142146 "./state/pull-requests": {
143147 "types": "./src/state/pullRequests.ts",
144148 "default": "./src/state/pullRequests.ts"
packages/client-runtime/src/state/providerInstanceDisplay.test.ts created+108
......@@ -0,0 +1,108 @@
1import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 normalizeProviderAccentColor,
6 providerInstanceInitials,
7 resolveProviderInstanceDisplayName,
8 shouldShowInstanceBadge,
9} from "./providerInstanceDisplay.ts";
10
11const codex = ProviderDriverKind.make("codex");
12const claude = ProviderDriverKind.make("claudeAgent");
13
14describe("resolveProviderInstanceDisplayName", () => {
15 it("keeps a snapshot name that differs from the brand label", () => {
16 expect(
17 resolveProviderInstanceDisplayName({
18 instanceId: ProviderInstanceId.make("codex"),
19 driver: codex,
20 displayName: "Work",
21 }),
22 ).toBe("Work");
23 });
24
25 it("humanizes a custom instance id when the snapshot only carries the brand label", () => {
26 expect(
27 resolveProviderInstanceDisplayName({
28 instanceId: ProviderInstanceId.make("codex_personal"),
29 driver: codex,
30 displayName: "Codex",
31 }),
32 ).toBe("Codex Personal");
33 });
34
35 it("uses the brand label for the default instance", () => {
36 expect(
37 resolveProviderInstanceDisplayName({
38 instanceId: ProviderInstanceId.make("codex"),
39 driver: codex,
40 }),
41 ).toBe("Codex");
42 });
43});
44
45describe("providerInstanceInitials", () => {
46 it("takes the first two characters of a single word", () => {
47 expect(providerInstanceInitials("Codex")).toBe("CO");
48 });
49
50 it("takes the first character of each of the first two words", () => {
51 expect(providerInstanceInitials("Codex Personal")).toBe("CP");
52 });
53
54 it("ignores words past the first two", () => {
55 expect(providerInstanceInitials("Codex Personal Backup Account")).toBe("CP");
56 });
57
58 it("returns an empty string for an empty label", () => {
59 expect(providerInstanceInitials("")).toBe("");
60 });
61
62 it("keeps an emoji whole instead of splitting its surrogate pair", () => {
63 expect(providerInstanceInitials("😀 Work")).toBe("😀W");
64 expect(providerInstanceInitials("😀")).toBe("😀");
65 });
66});
67
68describe("normalizeProviderAccentColor", () => {
69 it("accepts a lowercase hex color", () => {
70 expect(normalizeProviderAccentColor("#ff8800")).toBe("#ff8800");
71 });
72
73 it("accepts an uppercase hex color", () => {
74 expect(normalizeProviderAccentColor("#FF8800")).toBe("#FF8800");
75 });
76
77 it("rejects a non-hex value", () => {
78 expect(normalizeProviderAccentColor("blue")).toBeUndefined();
79 });
80
81 it("rejects a short hex value", () => {
82 expect(normalizeProviderAccentColor("#fff")).toBeUndefined();
83 });
84
85 it("treats undefined and blank as unset", () => {
86 expect(normalizeProviderAccentColor(undefined)).toBeUndefined();
87 expect(normalizeProviderAccentColor(" ")).toBeUndefined();
88 });
89});
90
91describe("shouldShowInstanceBadge", () => {
92 it("shows the badge when the entry has an accent color", () => {
93 const entry = { driverKind: codex, accentColor: "#ff8800" };
94 expect(shouldShowInstanceBadge(entry, [entry])).toBe(true);
95 });
96
97 it("shows the badge when two entries share a driver, even without an accent", () => {
98 const first = { driverKind: codex, accentColor: undefined };
99 const second = { driverKind: codex, accentColor: undefined };
100 expect(shouldShowInstanceBadge(first, [first, second])).toBe(true);
101 });
102
103 it("hides the badge for a single instance of a driver with no accent", () => {
104 const entry = { driverKind: codex, accentColor: undefined };
105 const other = { driverKind: claude, accentColor: undefined };
106 expect(shouldShowInstanceBadge(entry, [entry, other])).toBe(false);
107 });
108});
packages/client-runtime/src/state/providerInstanceDisplay.ts created+88
......@@ -0,0 +1,88 @@
1/**
2 * How a configured provider instance presents itself in a client: its label,
3 * its accent color, and whether its icon carries the account badge. Shared by
4 * web and mobile so both clients name and badge the same instance identically.
5 *
6 * @module providerInstanceDisplay
7 */
8import {
9 defaultInstanceIdForDriver,
10 PROVIDER_DISPLAY_NAMES,
11 type ProviderDriverKind,
12 type ServerProvider,
13} from "@t3tools/contracts";
14
15/**
16 * Title-case a slug: splits on `_` / `-` and camelCase boundaries, so
17 * `codex_personal` becomes "Codex Personal" and `myCustomInstance` becomes
18 * "My Custom Instance".
19 */
20function humanizeSlug(slug: string): string {
21 return slug
22 .replace(/([a-z])([A-Z])/g, "$1 $2")
23 .replace(/[_-]+/g, " ")
24 .trim()
25 .replace(/\b\w/g, (char) => char.toUpperCase());
26}
27
28/**
29 * Resolve an instance's label with a tiered priority:
30 *
31 * 1. A snapshot `displayName` that differs from the driver's brand label —
32 * the server has explicitly named this instance, trust it.
33 * 2. For non-default instances, a humanized `instanceId` — the server fell
34 * back to the driver-level label (the same for every instance of that
35 * kind), so the slug is what keeps "Codex" and "Codex Personal" apart.
36 * 3. The snapshot's `displayName`, or the brand label from contracts.
37 */
38export function resolveProviderInstanceDisplayName(
39 snapshot: Pick<ServerProvider, "instanceId" | "driver" | "displayName">,
40): string {
41 const trimmedSnapshotName = snapshot.displayName?.trim();
42 const kindLabel = PROVIDER_DISPLAY_NAMES[snapshot.driver] ?? humanizeSlug(snapshot.driver);
43 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) return trimmedSnapshotName;
44 if (snapshot.instanceId !== defaultInstanceIdForDriver(snapshot.driver)) {
45 const humanized = humanizeSlug(snapshot.instanceId);
46 if (humanized.length > 0) return humanized;
47 }
48 return trimmedSnapshotName || kindLabel;
49}
50
51/**
52 * Turn a display name into up to two initials for the badge: the first two
53 * characters of a single word, or the first character of each of the first
54 * two words. Iterates by code point so an emoji never splits into surrogates.
55 */
56export function providerInstanceInitials(label: string): string {
57 const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
58 if (words.length === 0) return "";
59 if (words.length === 1) return Array.from(words[0]!).slice(0, 2).join("").toUpperCase();
60 return words
61 .slice(0, 2)
62 .map((word) => Array.from(word)[0]?.toUpperCase() ?? "")
63 .join("");
64}
65
66/** Only `#rrggbb` accent colors render; anything else is treated as unset. */
67export function normalizeProviderAccentColor(value: string | undefined): string | undefined {
68 const trimmed = value?.trim();
69 if (!trimmed) return undefined;
70 return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined;
71}
72
73/**
74 * Whether an instance's icon carries the account badge: accent color set, or
75 * several instances sharing a driver so the brand glyph alone is ambiguous.
76 * Shared by the composer trigger, the picker rail, and sidebar/thread rows.
77 */
78export function shouldShowInstanceBadge(
79 entry: { readonly driverKind: ProviderDriverKind; readonly accentColor?: string | undefined },
80 entries: Iterable<{ readonly driverKind: ProviderDriverKind }>,
81): boolean {
82 if (entry.accentColor) return true;
83 let sharedDriverCount = 0;
84 for (const candidate of entries) {
85 if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true;
86 }
87 return false;
88}