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
7 changes: 4 additions & 3 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useId, useState } from "react";
import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./ui/AppText";
import { cn } from "../lib/cn";
import { getProviderClient } from "@t3tools/client-runtime/provider-clients";

type ProviderIconProps = {
Expand Down Expand Up @@ -158,7 +159,7 @@ export function ProviderInstanceIcon(props: {
readonly displayName: string;
readonly accentColor?: string;
readonly showBadge?: boolean;
readonly surfaceColor: string;
readonly surfaceColor?: string;
}) {
const accentColor = normalizeProviderAccentColor(props.accentColor);
return (
Expand All @@ -173,14 +174,14 @@ export function ProviderInstanceIcon(props: {
style={{ position: "absolute", right: -3, bottom: -3, width: 32, alignItems: "flex-end" }}
>
<View
className={accentColor ? undefined : "bg-card"}
className={cn(!accentColor && "bg-card", !props.surfaceColor && "border-screen")}
style={{
height: 12,
minWidth: 12,
paddingHorizontal: 2,
borderRadius: 999,
borderWidth: 1,
borderColor: props.surfaceColor,
...(props.surfaceColor ? { borderColor: props.surfaceColor } : {}),
backgroundColor: accentColor,
alignItems: "center",
justifyContent: "center",
Expand Down
59 changes: 46 additions & 13 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { useAtomValue } from "@effect/atom-react";
import { StackActions, useNavigation } from "@react-navigation/native";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
resolveProviderInstanceDisplayName,
shouldShowInstanceBadge,
} from "@t3tools/client-runtime/state/provider-instance-display";
import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
import type {
EnvironmentId,
OrchestrationV2Subagent,
OrchestrationV2TurnItem,
ServerProvider,
ServerConfig,
} from "@t3tools/contracts";
import { Pressable, View, type ColorValue } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { serverEnvironment } from "../../state/server";
Expand Down Expand Up @@ -42,21 +46,53 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin

function SubagentAvatar(props: {
readonly item: SubagentItem;
readonly provider: ServerProvider | undefined;
readonly config: ServerConfig | null | undefined;
}) {
const providers = props.config?.providers ?? [];
const provider = providers.find(
(candidate) => candidate.instanceId === props.item.providerInstanceId,
);
// A remote task's own driver is only a "remote" label.
const driver =
props.provider?.driver ??
(props.item.remoteChild === undefined ? props.item.driver : undefined);
provider?.driver ?? (props.item.remoteChild === undefined ? props.item.driver : undefined);
const badgeEntries = providers.map((candidate) => {
const rawConfig = props.config?.settings.providerInstances[candidate.instanceId]?.config;
const instanceConfig =
rawConfig !== null && typeof rawConfig === "object"
? (rawConfig as Readonly<Record<string, unknown>>)
: null;
const agentId = instanceConfig?.source === "local" ? undefined : instanceConfig?.agentId;
return {
...candidate,
driverKind: candidate.driver,
acpRegistryAgentId: typeof agentId === "string" ? agentId.trim() || undefined : undefined,
};
});
const badgeEntry = badgeEntries.find(
(candidate) => candidate.instanceId === props.item.providerInstanceId,
);
const showBadge =
provider !== undefined &&
badgeEntry !== undefined &&
shouldShowInstanceBadge(badgeEntry, badgeEntries);
return (
<View
accessible={false}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
className="h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"
>
{driver === undefined ? (
{showBadge ? (
<ProviderInstanceIcon
provider={provider.driver}
iconUrl={provider.iconUrl}
size={15}
displayName={resolveProviderInstanceDisplayName(provider)}
accentColor={provider.accentColor}
showBadge
/>
) : driver === undefined ? (
<SymbolView name="sparkles" size={13} tintColorClassName="accent-icon-muted" />
) : (
<ProviderIcon provider={driver} iconUrl={props.provider?.iconUrl} size={15} />
<ProviderIcon provider={driver} iconUrl={provider?.iconUrl} size={15} />
)}
</View>
);
Expand Down Expand Up @@ -100,11 +136,8 @@ export function ThreadSubagentGroup(props: {
const expanded = props.expandedRows[props.anchorKey] ?? false;
const serverConfigs = useServerConfigs();
// A task in a linked environment runs a provider instance of that environment.
const providerOf = (item: SubagentItem) =>
(item.remoteChild === undefined
? config
: serverConfigs.get(item.remoteChild.environmentId)
)?.providers.find((provider) => provider.instanceId === item.providerInstanceId);
const configOf = (item: SubagentItem) =>
item.remoteChild === undefined ? config : serverConfigs.get(item.remoteChild.environmentId);
return (
<WorkLogBlock>
{grouped ? (
Expand All @@ -118,7 +151,7 @@ export function ThreadSubagentGroup(props: {
<View className="flex-row items-center">
{agents.slice(0, 3).map((agent, index) => (
<View key={agent.item.id} style={{ marginLeft: index === 0 ? 0 : -7 }}>
<SubagentAvatar item={agent.item} provider={providerOf(agent.item)} />
<SubagentAvatar item={agent.item} config={configOf(agent.item)} />
</View>
))}
{agents.length > 3 ? (
Expand Down
35 changes: 30 additions & 5 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
import {
applyProviderInstanceSettings,
brandProviderInstanceEntries,
deriveProviderInstanceEntries,
shouldShowInstanceBadge,
type ProviderInstanceEntry,
Expand Down Expand Up @@ -206,12 +207,16 @@ function SubagentAvatar({
driver,
displayName,
iconUrl,
accentColor,
showBadge,
status,
className,
}: {
driver?: ProviderDriverKind | undefined;
displayName?: string | undefined;
iconUrl?: string | undefined;
accentColor?: string | undefined;
showBadge: boolean;
status?: OrchestrationV2TurnItem["status"] | undefined;
className?: string;
}) {
Expand All @@ -227,7 +232,10 @@ function SubagentAvatar({
<ProviderInstanceIcon
driverKind={driver}
displayName={displayName ?? driver}
accentColor={accentColor}
acpRegistryIconUrl={iconUrl}
showBadge={showBadge}
size="compact"
className="z-auto"
iconClassName="size-3.5"
/>
Expand All @@ -237,7 +245,8 @@ function SubagentAvatar({
{status ? (
<span
className={cn(
"absolute -right-px -bottom-px size-2 rounded-full ring-2 ring-background",
"absolute size-2 rounded-full ring-2 ring-background",
showBadge ? "-left-px -top-px" : "-right-px -bottom-px",
subagentStatusVisual(status).dotClass,
)}
/>
Expand All @@ -261,9 +270,13 @@ export function EnvironmentSubagentAvatar({
remote?: OrchestrationV2RemoteTaskChild | undefined;
status?: OrchestrationV2TurnItem["status"] | undefined;
}) {
const providersAtom = serverEnvironment.providersValueAtom(
remote?.environmentId ?? environmentId,
);
const providerEnvironmentId = remote?.environmentId ?? environmentId;
const providersAtom = serverEnvironment.providersValueAtom(providerEnvironmentId);
const settings = useAtomValue(serverEnvironment.settingsValueAtom(providerEnvironmentId));
const entries = (providers: ReadonlyArray<ServerProvider> | null | undefined) => {
const derived = deriveProviderInstanceEntries(providers ?? []);
return settings ? brandProviderInstanceEntries(derived, settings) : derived;
};
const providerDriver = useAtomValue(
providersAtom,
(providers) =>
Expand All @@ -272,8 +285,18 @@ export function EnvironmentSubagentAvatar({
const displayName = useAtomValue(
providersAtom,
(providers) =>
providers?.find((provider) => provider.instanceId === providerInstanceId)?.displayName,
entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.displayName,
);
const accentColor = useAtomValue(
providersAtom,
(providers) =>
entries(providers).find((entry) => entry.instanceId === providerInstanceId)?.accentColor,
);
const showBadge = useAtomValue(providersAtom, (providers) => {
const all = entries(providers);
const entry = all.find((candidate) => candidate.instanceId === providerInstanceId);
return entry !== undefined && shouldShowInstanceBadge(entry, all);
});
const iconUrl = useAtomValue(
providersAtom,
(providers) =>
Expand All @@ -285,6 +308,8 @@ export function EnvironmentSubagentAvatar({
driver={providerDriver ?? (remote === undefined ? driver : undefined)}
displayName={displayName}
iconUrl={iconUrl}
accentColor={accentColor}
showBadge={showBadge}
/>
);
}
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/providerInstances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { describe, expect, it } from "vite-plus/test";
import {
applyProviderInstanceSettings,
brandProviderInstanceEntries,
deriveProviderEntriesByEnvironment,
deriveProviderInstanceEntries,
getDefaultProviderInstanceModel,
Expand Down Expand Up @@ -254,6 +255,32 @@ describe("deriveProviderEntriesByEnvironment", () => {
});
});

describe("brandProviderInstanceEntries", () => {
it("keeps an instance a project turns on as a sibling for the instance badge", () => {
const codex = ProviderDriverKind.make("codex");
const settings = {
providerInstances: {
[ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false },
},
projectSettingsOverrides: {
[ProjectId.make("project-work")]: {
providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } },
},
},
};
const entries = brandProviderInstanceEntries(
deriveProviderInstanceEntries([
provider({ provider: codex, instanceId: "codex" }),
provider({ provider: codex, instanceId: "codex_work" }),
]),
settings,
);
const personal = entries.find((entry) => entry.instanceId === "codex");

expect(personal && shouldShowInstanceBadge(personal, entries)).toBe(true);
});
});

describe("resolveSelectableProviderInstance", () => {
it("falls back to the first enabled and available instance", () => {
const disabled = ProviderInstanceId.make("codex");
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/providerInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,7 @@ export function deriveProviderEntriesByEnvironment(
const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
for (const [environmentId, providers, settings] of providersByEnvironment) {
const derived = deriveProviderInstanceEntries(providers);
// The snapshot includes instances enabled by any project; machine settings alone do not.
const entries = settings
? derived.map((entry) => applyProviderInstanceBranding(entry, settings))
: derived;
const entries = settings ? brandProviderInstanceEntries(derived, settings) : derived;
byEnvironment.set(
environmentId,
new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)),
Expand All @@ -105,6 +102,14 @@ export function deriveProviderEntriesByEnvironment(
return byEnvironment;
}

// The snapshot includes instances enabled by any project; machine settings alone do not.
export function brandProviderInstanceEntries(
entries: ReadonlyArray<ProviderInstanceEntry>,
settings: Pick<ServerSettings, "providerInstances">,
): ReadonlyArray<ProviderInstanceEntry> {
return entries.map((entry) => applyProviderInstanceBranding(entry, settings));
}

/** projectId applies project overrides; explicit null uses machine settings. */
export function applyProviderInstanceSettings(
entries: ReadonlyArray<ProviderInstanceEntry>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ describe("shouldShowInstanceBadge", () => {
expect(shouldShowInstanceBadge(entry, [entry])).toBe(true);
});

it("ignores a non-hex accent color", () => {
const entry = { driverKind: codex, accentColor: "blue", enabled: true };
expect(shouldShowInstanceBadge(entry, [entry])).toBe(false);
});

it("shows the badge when two entries share a driver, even without an accent", () => {
const first = { driverKind: codex, accentColor: undefined, enabled: true };
const second = { driverKind: codex, accentColor: undefined, enabled: true };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function shouldShowInstanceBadge(
readonly enabled: boolean;
}>,
): boolean {
if (entry.accentColor) return true;
if (normalizeProviderAccentColor(entry.accentColor)) return true;
let sharedProviderCount = 0;
for (const candidate of entries) {
if (candidate.driverKind !== entry.driverKind) continue;
Expand Down
Loading