| author | |
| committer | |
| log | 2c8e95a4b641898322c7312cb67535612606745e |
| tree | 7ab26456d0684b1f4df488d1aafd435fdf48e79b |
| parent | 09e8de9c655ae85410bf6b00446f272a01da81c7 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 432 insertions(+), 124 deletions(-)
apps/mobile/src/components/ProviderIcon.tsx+58| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { Image } from "expo-image"; |
| 2 | 2 | import { Path, Svg } from "react-native-svg"; |
| 3 | import { View } from "react-native"; | |
| 4 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; | |
| 3 | 5 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 6 | import { AppText as Text } from "./AppText"; | |
| 4 | 7 | |
| 5 | 8 | type ProviderIconProps = { |
| 6 | 9 | readonly provider: string | null | undefined; |
| ... | ... | @@ -80,3 +83,58 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 80 | 83 | </Svg> |
| 81 | 84 | ); |
| 82 | 85 | } |
| 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 | */ | |
| 94 | export 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 { |
| 51 | 51 | ThreadListV2SettledShelfHeader, |
| 52 | 52 | ThreadListV2SnoozedShelfHeader, |
| 53 | 53 | } from "../threads/thread-list-v2-items"; |
| 54 | import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; | |
| 54 | 55 | import { |
| 55 | 56 | buildThreadListV2Items, |
| 56 | 57 | getThreadListV2OrderedSection, |
| ... | ... | @@ -840,15 +841,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 840 | 841 | projectTitle={v2ProjectTitleByProjectKey.get( |
| 841 | 842 | scopedProjectKey(thread.environmentId, thread.projectId), |
| 842 | 843 | )} |
| 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)} | |
| 852 | 845 | environmentLabel={ |
| 853 | 846 | Object.keys(props.savedConnectionsById).length > 1 |
| 854 | 847 | ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+2-9| ... | ... | @@ -78,6 +78,7 @@ import { |
| 78 | 78 | ThreadListV2SettledShelfHeader, |
| 79 | 79 | ThreadListV2SnoozedShelfHeader, |
| 80 | 80 | } from "./thread-list-v2-items"; |
| 81 | import { resolveThreadProviderInstance } from "./thread-provider-instance"; | |
| 81 | 82 | import { |
| 82 | 83 | buildThreadListV2Items, |
| 83 | 84 | getThreadListV2OrderedSection, |
| ... | ... | @@ -901,15 +902,7 @@ function ThreadNavigationSidebarPane( |
| 901 | 902 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 902 | 903 | project={projectByKey.get(scopeKey) ?? null} |
| 903 | 904 | 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)} | |
| 913 | 906 | environmentLabel={ |
| 914 | 907 | Object.keys(savedConnectionsById).length > 1 |
| 915 | 908 | ? (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"; |
| 16 | 16 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 17 | 17 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 18 | 18 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 19 | import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 19 | import { ProviderInstanceIcon } from "../../components/ProviderIcon"; | |
| 20 | import type { ThreadRowProviderInstance } from "./thread-provider-instance"; | |
| 20 | 21 | import { cn } from "../../lib/cn"; |
| 21 | 22 | import { relativeTime } from "../../lib/time"; |
| 22 | 23 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| ... | ... | @@ -349,7 +350,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 349 | 350 | readonly snoozePresetMinute: string; |
| 350 | 351 | readonly project: EnvironmentProject | null; |
| 351 | 352 | readonly projectTitle?: string; |
| 352 | readonly providerDriver: string | null; | |
| 353 | readonly providerInstance: ThreadRowProviderInstance | null; | |
| 353 | 354 | /** Which machine hosts the thread. Null when only one environment is |
| 354 | 355 | connected — repeating the same label on every row is noise. Mirrors |
| 355 | 356 | the web sidebar's remote-environment cloud icon, but as text since |
| ... | ... | @@ -435,6 +436,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 435 | 436 | const selectedBackgroundColor = theme["--color-user-bubble"]; |
| 436 | 437 | const sidebarPane = props.pane === "sidebar"; |
| 437 | 438 | 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; | |
| 438 | 448 | |
| 439 | 449 | const status = resolveThreadListV2Status(thread); |
| 440 | 450 | const statusLabel = STATUS_LABEL_BY_STATUS[status]; |
| ... | ... | @@ -825,10 +835,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 825 | 835 | #{pr.label} |
| 826 | 836 | </Text> |
| 827 | 837 | ) : 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 | /> | |
| 832 | 847 | ) : null} |
| 833 | 848 | </View> |
| 834 | 849 | </> |
apps/mobile/src/features/threads/thread-provider-instance.test.ts created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | ProjectId, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | type ServerConfig, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { describe, expect, it } from "vite-plus/test"; | |
| 10 | ||
| 11 | import { resolveThreadProviderInstance } from "./thread-provider-instance"; | |
| 12 | ||
| 13 | function 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 | ||
| 24 | function 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 | ||
| 49 | describe("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 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import { | |
| 3 | normalizeProviderAccentColor, | |
| 4 | resolveProviderInstanceDisplayName, | |
| 5 | shouldShowInstanceBadge, | |
| 6 | } from "@t3tools/client-runtime/state/provider-instance-display"; | |
| 7 | import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | /** What a thread row needs to draw the provider glyph and its account badge. */ | |
| 10 | export 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 | */ | |
| 22 | export 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 @@ |
| 1 | 1 | import { type CSSProperties, memo } from "react"; |
| 2 | 2 | import { type ProviderDriverKind } from "@t3tools/contracts"; |
| 3 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; | |
| 3 | 4 | |
| 4 | 5 | import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; |
| 5 | 6 | import { cn } from "~/lib/utils"; |
| 6 | 7 | |
| 7 | export 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 | } | |
| 8 | export { providerInstanceInitials }; | |
| 16 | 9 | |
| 17 | 10 | export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 18 | 11 | driverKind: ProviderDriverKind; |
apps/web/src/providerInstances.ts+7-91| ... | ... | @@ -15,7 +15,6 @@ |
| 15 | 15 | import { |
| 16 | 16 | DEFAULT_MODEL_BY_PROVIDER, |
| 17 | 17 | defaultInstanceIdForDriver, |
| 18 | PROVIDER_DISPLAY_NAMES, | |
| 19 | 18 | resolveProviderInstanceEnabled, |
| 20 | 19 | type ModelSelection, |
| 21 | 20 | type ProviderDriverKind, |
| ... | ... | @@ -25,8 +24,13 @@ import { |
| 25 | 24 | type ServerSettings, |
| 26 | 25 | type ServerProviderState, |
| 27 | 26 | } from "@t3tools/contracts"; |
| 27 | import { | |
| 28 | normalizeProviderAccentColor, | |
| 29 | resolveProviderInstanceDisplayName, | |
| 30 | shouldShowInstanceBadge, | |
| 31 | } from "@t3tools/client-runtime/state/provider-instance-display"; | |
| 28 | 32 | |
| 29 | import { formatProviderDriverKindLabel } from "./providerModels"; | |
| 33 | export { normalizeProviderAccentColor, shouldShowInstanceBadge }; | |
| 30 | 34 | |
| 31 | 35 | /** |
| 32 | 36 | * Local-only placeholder used while a draft has no provider it can safely |
| ... | ... | @@ -80,93 +84,6 @@ export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): b |
| 80 | 84 | return entry.enabled; |
| 81 | 85 | } |
| 82 | 86 | |
| 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 | */ | |
| 97 | function 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 | ||
| 109 | function 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 | */ | |
| 118 | export 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 | ||
| 130 | export 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 | */ | |
| 152 | function 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 | ||
| 170 | 87 | /** |
| 171 | 88 | * Project the wire `ServerProvider[]` into instance entries, one per |
| 172 | 89 | * configured instance. Preserves the server's ordering (which sources |
| ... | ... | @@ -182,11 +99,10 @@ export function deriveProviderInstanceEntries( |
| 182 | 99 | const driverKind = snapshot.driver; |
| 183 | 100 | const defaultId = defaultInstanceIdForDriver(driverKind); |
| 184 | 101 | const isDefault = instanceId === defaultId; |
| 185 | const displayName = resolveInstanceDisplayName(snapshot, instanceId, driverKind, isDefault); | |
| 186 | 102 | return { |
| 187 | 103 | instanceId, |
| 188 | 104 | driverKind, |
| 189 | displayName, | |
| 105 | displayName: resolveProviderInstanceDisplayName(snapshot), | |
| 190 | 106 | accentColor: normalizeProviderAccentColor(snapshot.accentColor), |
| 191 | 107 | continuationGroupKey: snapshot.continuation?.groupKey, |
| 192 | 108 | enabled: snapshot.enabled, |
packages/client-runtime/package.json+4| ... | ... | @@ -139,6 +139,10 @@ |
| 139 | 139 | "types": "./src/state/projects.ts", |
| 140 | 140 | "default": "./src/state/projects.ts" |
| 141 | 141 | }, |
| 142 | "./state/provider-instance-display": { | |
| 143 | "types": "./src/state/providerInstanceDisplay.ts", | |
| 144 | "default": "./src/state/providerInstanceDisplay.ts" | |
| 145 | }, | |
| 142 | 146 | "./state/pull-requests": { |
| 143 | 147 | "types": "./src/state/pullRequests.ts", |
| 144 | 148 | "default": "./src/state/pullRequests.ts" |
packages/client-runtime/src/state/providerInstanceDisplay.test.ts created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | normalizeProviderAccentColor, | |
| 6 | providerInstanceInitials, | |
| 7 | resolveProviderInstanceDisplayName, | |
| 8 | shouldShowInstanceBadge, | |
| 9 | } from "./providerInstanceDisplay.ts"; | |
| 10 | ||
| 11 | const codex = ProviderDriverKind.make("codex"); | |
| 12 | const claude = ProviderDriverKind.make("claudeAgent"); | |
| 13 | ||
| 14 | describe("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 | ||
| 45 | describe("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 | ||
| 68 | describe("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 | ||
| 91 | describe("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 | */ | |
| 8 | import { | |
| 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 | */ | |
| 20 | function 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 | */ | |
| 38 | export 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 | */ | |
| 56 | export 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. */ | |
| 67 | export 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 | */ | |
| 78 | export 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 | } |