Skip to content
Open
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
24 changes: 24 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider
import { useState } from "react";
import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import type { ProviderBadge } from "../lib/modelOptions";
import { AppText as Text } from "./AppText";

type ProviderIconProps = {
Expand Down Expand Up @@ -210,3 +211,26 @@ export function ProviderInstanceIcon(props: {
</View>
);
}

/** A provider glyph that carries the account badge when its instance needs one. */
export function ProviderAccountIcon(props: {
readonly provider: string | null | undefined;
readonly iconUrl?: string | null | undefined;
readonly badge: ProviderBadge | undefined;
readonly size: number;
readonly surfaceColor: string;
}) {
return props.badge ? (
<ProviderInstanceIcon
provider={props.provider}
iconUrl={props.iconUrl}
size={props.size}
displayName={props.badge.displayName}
{...(props.badge.accentColor ? { accentColor: props.badge.accentColor } : {})}
showBadge
surfaceColor={props.surfaceColor}
/>
) : (
<ProviderIcon provider={props.provider} iconUrl={props.iconUrl} size={props.size} />
);
}
15 changes: 11 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
} from "../../state/composer-attachment-uploads";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderAccountIcon } from "../../components/ProviderIcon";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
Expand Down Expand Up @@ -651,6 +651,7 @@ export function NewTaskDraftScreen(props: {

const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
const composerSurfaceColor = theme["--color-composer-surface"];
const regularFontFamily = useFontFamily("regular");
const bodyText = useScaledTextRole("body");

Expand Down Expand Up @@ -1744,14 +1745,20 @@ export function NewTaskDraftScreen(props: {
<View className="min-w-0 flex-1 flex-row items-center justify-end gap-2">
<View className="min-w-0 shrink">
<ComposerInlineControl
accessibilityLabel="Model and reasoning settings"
accessibilityLabel={
flow.selectedModelOption?.providerBadge
? `Model and reasoning settings, ${flow.selectedModelOption.providerBadge.displayName} account`
: "Model and reasoning settings"
}
disabled={isComposerInteractionLocked}
emphasized
renderIcon={(size) => (
<ProviderIcon
iconUrl={flow.selectedModelOption?.providerIconUrl}
<ProviderAccountIcon
provider={flow.selectedModelOption?.providerDriver}
iconUrl={flow.selectedModelOption?.providerIconUrl}
badge={flow.selectedModelOption?.providerBadge}
size={size}
surfaceColor={composerSurfaceColor}
/>
)}
label={flow.selectedModelOption?.label ?? "Choose model"}
Expand Down
20 changes: 14 additions & 6 deletions apps/mobile/src/features/threads/ProviderSubagentBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useEffect, useState } from "react";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderAccountIcon } from "../../components/ProviderIcon";
import type { ProviderBadge } from "../../lib/modelOptions";
import { RequestActionButton } from "./RequestActionButton";

/**
Expand All @@ -16,8 +17,12 @@ import { RequestActionButton } from "./RequestActionButton";
* model is working, for how long, and leads back to the parent.
*/
export function ProviderSubagentBar(props: {
/** Driver and catalog icon of the provider running the subagent. */
readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null;
/** Driver, catalog icon and account badge of the provider running the subagent. */
readonly provider: {
readonly driver: string;
readonly iconUrl?: string | undefined;
readonly badge?: ProviderBadge | undefined;
} | null;
readonly modelLabel: string;
/** Reasoning effort as the composer names it, when the subagent has one. */
readonly effortLabel: string | null;
Expand All @@ -41,15 +46,18 @@ export function ProviderSubagentBar(props: {
{/* Only the text is one element, so "Open parent" stays reachable. */}
<View
accessible
accessibilityLabel={`${modelDescription} subagent, ${statusLabel}. It runs on its own and cannot take messages.`}
accessibilityLabel={`${modelDescription} subagent${props.provider?.badge ? ` on the ${props.provider.badge.displayName} account` : ""}, ${statusLabel}. It runs on its own and cannot take messages.`}
className="min-w-0 flex-1 gap-0.5"
>
<View className="min-w-0 flex-row items-center gap-1.5">
{props.provider ? (
<ProviderIcon
iconUrl={props.provider.iconUrl}
<ProviderAccountIcon
provider={props.provider.driver}
iconUrl={props.provider.iconUrl}
badge={props.provider.badge}
size={16}
// Theme colours aren't readable here, so the ring stays transparent.
surfaceColor="transparent"
/>
) : null}
<Text numberOfLines={1} className="min-w-0 shrink font-t3-bold text-sm text-foreground">
Expand Down
18 changes: 13 additions & 5 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import {
ComposerInlineControl,
ComposerToolbarRow,
} from "../../components/ComposerToolbar";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderAccountIcon } from "../../components/ProviderIcon";
import {
composerStripAttachments,
type DraftComposerAttachment,
Expand Down Expand Up @@ -377,7 +377,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const { themeVariables: materialTheme } = useAppearancePreferences();
const composerPanel = materialTheme["--color-composer-panel"];
const navigation = useNavigation();
const foregroundColor = useUniwindTheme()["--color-foreground"];
const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
const composerSurfaceColor = theme["--color-composer-surface"];
const bodyText = useScaledTextRole("body");
const fallbackInputRef = useRef<ComposerEditorHandle>(null);
const inputRef = props.editorRef ?? fallbackInputRef;
Expand Down Expand Up @@ -1120,13 +1122,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
<View className="min-w-0 shrink">
<ComposerInlineControl
accessibilityLabel="Model and reasoning settings"
accessibilityLabel={
currentModelOption?.providerBadge
? `Model and reasoning settings, ${currentModelOption.providerBadge.displayName} account`
: "Model and reasoning settings"
}
emphasized
renderIcon={(size) => (
<ProviderIcon
iconUrl={currentModelOption?.providerIconUrl}
<ProviderAccountIcon
provider={currentModelOption?.providerDriver}
iconUrl={currentModelOption?.providerIconUrl}
badge={currentModelOption?.providerBadge}
Comment thread
vitalyiegorov marked this conversation as resolved.
size={size}
surfaceColor={composerSurfaceColor}
/>
)}
label={currentModelOption?.label ?? currentModelSelection.model}
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { resolveProviderBadge } from "../../lib/modelOptions";
import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
import { deviceEnvironment } from "../../state/device";
import { useEnvironmentQuery } from "../../state/query";
Expand Down Expand Up @@ -1310,7 +1311,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}}
>
<ProviderSubagentBar
provider={providerSubagentProvider ?? null}
provider={
providerSubagentProvider
? {
...providerSubagentProvider,
badge: resolveProviderBadge(
props.serverConfig,
providerSubagentProvider,
),
}
: null
}
modelLabel={
providerSubagentCatalogModel?.name ??
formatModelSlugName(props.selectedThread.modelSelection.model)
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
import { MaterialButton } from "../../components/MaterialButton";
import { MaterialIconButton } from "../../components/MaterialIconButton";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ProviderAccountIcon } from "../../components/ProviderIcon";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import type { ModelOption, ProviderBadge, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
Expand Down Expand Up @@ -130,15 +130,23 @@ const FAVORITES_PROVIDER_FILTER = "@favorites";
function ProviderHeader(props: {
readonly driver: string | undefined;
readonly iconUrl: string | undefined;
readonly badge: ProviderBadge | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
readonly modelCount: number;
readonly onToggle: () => void;
}) {
const sheetColor = useUniwindTheme()["--color-sheet-solid"];
const content = (
<>
<ProviderIcon iconUrl={props.iconUrl} provider={props.driver} size={15} />
<ProviderAccountIcon
provider={props.driver}
iconUrl={props.iconUrl}
badge={props.badge}
size={15}
surfaceColor={sheetColor}
/>
<Text className="text-sm font-t3-medium text-foreground-muted">{props.label}</Text>
{props.collapsible ? (
<>
Expand Down Expand Up @@ -547,6 +555,7 @@ type ThreadSettingsProviderCatalog = {
readonly key: string;
readonly driver: string | undefined;
readonly iconUrl: string | undefined;
readonly badge: ProviderBadge | undefined;
readonly label: string;
readonly collapsible: boolean;
readonly collapsed: boolean;
Expand Down Expand Up @@ -616,6 +625,7 @@ function ThreadSettingsProviderListHeader(props: {
collapsed={props.provider.collapsed}
driver={props.provider.driver}
iconUrl={props.provider.iconUrl}
badge={props.provider.badge}
label={props.provider.label}
modelCount={props.provider.modelCount}
onToggle={onToggle}
Expand Down Expand Up @@ -678,6 +688,7 @@ function useThreadSettingsCatalogItems(
key: group.providerKey,
driver,
iconUrl: group.models[0]?.providerIconUrl,
badge: group.models[0]?.providerBadge,
label: group.providerLabel,
collapsible,
collapsed,
Expand Down
62 changes: 62 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,68 @@ describe("mobile model options", () => {
]);
});

it("badges the account when two instances share a provider or one has an accent", () => {
const claude = (instanceId: string, extra: Record<string, unknown> = {}) => ({
instanceId,
driver: "claudeAgent",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{ slug: "claude-opus-5-5", name: "Claude Opus 5.5", isCustom: false, capabilities: null },
],
...extra,
});
const badges = (providers: ReadonlyArray<unknown>) =>
buildModelOptions({ providers } as unknown as ServerConfig, null).map(
(option) => option.providerBadge,
);

expect(badges([claude("claudeAgent")])).toEqual([undefined]);
expect(
badges([claude("claudeAgent", { displayName: "Work", accentColor: "#eae10c" })]),
).toEqual([{ displayName: "Work", accentColor: "#eae10c" }]);
expect(
badges([
claude("claudeAgent", { displayName: "Personal" }),
claude("claude_work", { displayName: "Work" }),
]),
).toEqual([{ displayName: "Personal" }, { displayName: "Work" }]);

// Different ACP agents have their own glyphs, so they need no badge.
const acp = (instanceId: string) => ({
...claude(instanceId),
driver: "acpRegistry",
});
const acpConfig = {
providers: [acp("acp_a"), acp("acp_b")],
settings: {
providerInstances: {
acp_a: { driver: "acpRegistry", config: { agentId: "agent-a" } },
acp_b: { driver: "acpRegistry", config: { agentId: "agent-b" } },
},
},
} as unknown as ServerConfig;
expect(buildModelOptions(acpConfig, null).map((option) => option.providerBadge)).toEqual([
undefined,
undefined,
]);

// Local ACP commands have no registry agent, so two of them share a glyph.
const localConfig = {
...acpConfig,
settings: {
providerInstances: {
acp_a: { driver: "acpRegistry", config: { source: "local", agentId: "agent-a" } },
acp_b: { driver: "acpRegistry", config: { source: "local", agentId: "agent-b" } },
},
},
} as unknown as ServerConfig;
expect(
buildModelOptions(localConfig, null).every((option) => option.providerBadge !== undefined),
).toBe(true);
});

it("carries configured ACP identity into model and provider catalogs", () => {
const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg";
const config = {
Expand Down
Loading
Loading