Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -80,3 +83,58 @@ export function ProviderIcon(props: ProviderIconProps) {
</Svg>
);
}

/**
* `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 (
<View style={{ position: "relative" }}>
<View style={{ opacity: 0.6 }}>
<ProviderIcon provider={props.provider} size={props.size} />
</View>
{props.showBadge ? (
<View
className={props.accentColor ? undefined : "bg-card"}
style={{
position: "absolute",
right: -3,
bottom: -3,
height: 12,
minWidth: 12,
paddingHorizontal: 2,
borderRadius: 999,
borderWidth: 1,
borderColor: props.surfaceColor,
backgroundColor: props.accentColor,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
className={props.accentColor ? undefined : "text-foreground-muted"}
style={{
fontSize: 7,
fontWeight: "600",
lineHeight: 9,
color: props.accentColor ? "#ffffff" : undefined,
}}
>
{providerInstanceInitials(props.displayName)}
</Text>
</View>
) : null}
</View>
);
}
11 changes: 2 additions & 9 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2SnoozedShelfHeader,
} from "../threads/thread-list-v2-items";
import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 2 additions & 9 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2SnoozedShelfHeader,
} from "./thread-list-v2-items";
import { resolveThreadProviderInstance } from "./thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 21 additions & 6 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -825,10 +835,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
#{pr.label}
</Text>
) : null}
{props.providerDriver ? (
<View className="opacity-60">
<ProviderIcon provider={props.providerDriver} size={14} />
</View>
{props.providerInstance ? (
<ProviderInstanceIcon
provider={props.providerInstance.driverKind}
size={14}
displayName={props.providerInstance.displayName}
accentColor={props.providerInstance.accentColor}
showBadge={props.providerInstance.showBadge}
surfaceColor={providerIconSurfaceColor}
/>
) : null}
</View>
</>
Expand Down
98 changes: 98 additions & 0 deletions apps/mobile/src/features/threads/thread-provider-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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<EnvironmentId, ServerConfig>([
[
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, ServerConfig>([
[
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, ServerConfig>([
[environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])],
]);
const thread = makeThread(environmentId, "codex");

expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
});
});
42 changes: 42 additions & 0 deletions apps/mobile/src/features/threads/thread-provider-instance.ts
Original file line number Diff line number Diff line change
@@ -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<EnvironmentId, ServerConfig>,
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 })),
),
};
}
11 changes: 2 additions & 9 deletions apps/web/src/components/chat/ProviderInstanceIcon.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading