diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index 7c71ac2680de1..bcfc1901b8630 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -5,10 +5,11 @@ import { shouldShowInstanceBadge, } from "@t3tools/client-runtime/state/provider-instance-display"; import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; -import { formatSubagentEffort } from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { View } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -17,7 +18,14 @@ import { cn } from "../../lib/cn"; import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; import { subagentCardDetail } from "./subagent-card-presentation"; import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { + resolveSubagentRowPresentation, + resolveSubagentTraits, + subagentModelSelection, + type SubagentTraits, +} from "./threadAgentsPresentation"; + +const ThemedPath = withUniwind(Path); type SubagentRowSubagent = Pick< OrchestrationV2Subagent, @@ -25,6 +33,7 @@ type SubagentRowSubagent = Pick< | "childThreadId" | "model" | "modelSelection" + | "origin" | "driver" | "providerInstanceId" | "title" @@ -134,7 +143,7 @@ function SubagentMetadata(props: { parent ? scopeProjectRef(environmentId, parent.projectId) : null, ); const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); - const selection = subagent.modelSelection; + const selection = subagentModelSelection(subagent, child); const { modelLabel, modelSlug, workspace } = resolveSubagentMetadata({ model: subagent.model, modelSelection: selection, @@ -144,7 +153,12 @@ function SubagentMetadata(props: { parentProject, childProject, }); - const effort = formatSubagentEffort({ modelSlug, modelSelection: selection, provider }); + const traits = resolveSubagentTraits({ + modelSlug, + selection, + providerInstanceId: subagent.providerInstanceId, + provider, + }); return ( {showBadge ? ( @@ -166,8 +180,8 @@ function SubagentMetadata(props: { {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} {modelLabel} - {effort ? ` · ${effort}` : ""} + {workspace.map(({ label, value }) => ( // Preserve the label against flattening without making it a separate accessibility stop. ); } + +function SubagentTraitsLabel(props: { readonly traits: SubagentTraits }) { + const { effort, speedIcon } = props.traits; + if (!effort && !speedIcon) return null; + const speedLabel = + speedIcon === "ultrafast" ? "Ultrafast mode on" : speedIcon ? "Fast mode on" : null; + return ( + + + · + + {speedIcon ? : null} + {effort ? ( + + {effort} + + ) : null} + + ); +} + +// Same glyphs as the web composer's speed icons. +function SpeedIcon(props: { readonly speedIcon: "fast" | "ultrafast" }) { + return ( + + {props.speedIcon === "ultrafast" ? ( + <> + + + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/threads/thread-workflow-opening.tsx b/apps/mobile/src/features/threads/thread-workflow-opening.tsx index 9919e2e9854b7..a26d98a8955fc 100644 --- a/apps/mobile/src/features/threads/thread-workflow-opening.tsx +++ b/apps/mobile/src/features/threads/thread-workflow-opening.tsx @@ -65,6 +65,7 @@ function memberRowSubagent( childThreadId: member.childThreadId as ThreadId | null, model: member.model, modelSelection: workflowMemberModelSelection(member, opening.providerInstanceId!), + origin: "provider_native" as const, driver: opening.driver!, providerInstanceId: opening.providerInstanceId!, title: member.title, diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts index 171d73a148597..1560601965cff 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts @@ -6,8 +6,10 @@ import { import { NodeId, ProviderDriverKind, + ProviderInstanceId, RunId, ThreadId, + type ModelSelection, type OrchestrationV2SubagentWorkflow, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -16,6 +18,8 @@ import { nextWorkflowSelection, resolveSubagentRowPresentation, resolveSubagentStopTarget, + resolveSubagentTraits, + subagentModelSelection, workflowMemberDetail, workflowMemberMetadata, workflowRail, @@ -141,6 +145,82 @@ const agent = ( ...overrides, }); +describe("resolveSubagentTraits", () => { + const instance = ProviderInstanceId.make("codex"); + const provider = { + driver: ProviderDriverKind.make("codex"), + models: [ + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Effort", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }, + { + id: "serviceTier", + label: "Speed", + type: "select" as const, + options: [ + { id: "default", label: "Normal" }, + { id: "priority", label: "Fast" }, + { id: "ultra", label: "Ultrafast" }, + ], + }, + ], + }, + }, + ], + }; + const selection = (tier: string, overrides: Partial = {}): ModelSelection => ({ + instanceId: instance, + model: "gpt-test", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: tier }, + ], + ...overrides, + }); + const traits = (value: ModelSelection | undefined) => + resolveSubagentTraits({ + modelSlug: "gpt-test", + selection: value, + providerInstanceId: instance, + provider, + }); + + it.each([ + ["priority", "fast"], + ["ultra", "ultrafast"], + ["default", null], + ] as const)("labels effort and service tier %s", (tier, speedIcon) => { + expect(traits(selection(tier))).toEqual({ effort: "High", speedIcon }); + }); + + it("labels nothing for another instance's or model's selection", () => { + const empty = { effort: null, speedIcon: null }; + expect(traits(selection("priority", { instanceId: ProviderInstanceId.make("other") }))).toEqual( + empty, + ); + expect(traits(selection("priority", { model: "other-model" }))).toEqual(empty); + }); + + it("falls back to the child thread's selection only for app-owned agents", () => { + const childThread = { modelSelection: selection("ultra") }; + expect( + subagentModelSelection({ origin: "app_owned", modelSelection: undefined }, childThread), + ).toBe(childThread.modelSelection); + expect( + subagentModelSelection({ origin: "provider_native", modelSelection: undefined }, childThread), + ).toBeUndefined(); + }); +}); + describe("workflowRail", () => { it("keeps declared phases without agents and gives unphased agents a stop", () => { const rail = workflowRailOf([ diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index 7f560497da254..ff04380887a7f 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -8,10 +8,16 @@ import { type RuntimeSubagent, type SubagentWorkflowGroup, } from "@t3tools/client-runtime/state/subagentRuntime"; -import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; +import { getSelectionSpeedIcon } from "@t3tools/client-runtime/model-traits"; +import { + formatSubagentEffort, + REASONING_EFFORT_OPTION_IDS, +} from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId, + ModelSelection, OrchestrationV2Subagent, + OrchestrationV2ThreadShell, ServerProvider, ThreadId, } from "@t3tools/contracts"; @@ -110,6 +116,49 @@ export function resolveSubagentRowPresentation( }; } +/** The selection an agent ran with; an app-owned agent that saved none ran with its thread's. */ +export function subagentModelSelection( + subagent: Pick, + childThread: Pick | undefined, +): ModelSelection | undefined { + return ( + subagent.modelSelection ?? + (subagent.origin === "app_owned" ? childThread?.modelSelection : undefined) + ); +} + +export interface SubagentTraits { + readonly effort: string | null; + readonly speedIcon: "fast" | "ultrafast" | null; +} + +/** Labels a selection only when it is for the agent's own model and provider instance. */ +export function resolveSubagentTraits(input: { + readonly modelSlug: string | null; + readonly selection: ModelSelection | undefined; + readonly providerInstanceId: OrchestrationV2Subagent["providerInstanceId"]; + readonly provider: Pick | undefined; +}): SubagentTraits { + const { modelSlug, selection, provider } = input; + if ( + !provider || + !selection || + !modelSlug || + selection.instanceId !== input.providerInstanceId || + (resolveSelectableModel(provider.driver, selection.model, provider.models) ?? + selection.model.trim()) !== modelSlug + ) { + return { effort: null, speedIcon: null }; + } + const descriptors = + provider.models.find((model) => model.slug === modelSlug)?.capabilities?.optionDescriptors ?? + []; + return { + effort: formatSubagentEffort({ modelSlug, modelSelection: selection, provider }), + speedIcon: getSelectionSpeedIcon(descriptors, selection), + }; +} + export type WorkflowRailTone = SubagentRowTone | "queued"; export interface WorkflowRailItem { diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 50b50273ba965..ceb50f966db23 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -13,7 +13,8 @@ import { } from "@t3tools/client-runtime/state/subagent-display"; import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; -import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; +import { getSelectionSpeedIcon } from "@t3tools/client-runtime/model-traits"; +import { TraitsSpeedIcon } from "./TraitsSpeed"; import type { ReactNode } from "react"; import { BotIcon, @@ -75,35 +76,10 @@ export function SubagentTooltipContent(props: { const effort = REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(matchingSelection, id), ).find(Boolean); - const speed = provider - ? providerModel?.capabilities?.optionDescriptors - ?.map((descriptor) => { - const saved = matchingSelection?.options?.find((option) => option.id === descriptor.id); - if ( - descriptor.id === "fastMode" && - descriptor.type === "boolean" && - typeof saved?.value === "boolean" - ) { - return getTraitsSpeedDisplay({ - ...descriptor, - currentValue: saved.value, - }); - } - if ( - descriptor.id === "serviceTier" && - descriptor.type === "select" && - typeof saved?.value === "string" && - descriptor.options.some((option) => option.id === saved.value) - ) { - return getTraitsSpeedDisplay({ - ...descriptor, - currentValue: saved.value, - }); - } - return null; - }) - .find(Boolean) - : undefined; + const speedIcon = getSelectionSpeedIcon( + providerModel?.capabilities?.optionDescriptors ?? [], + matchingSelection, + ); const driver = props.provider?.driver ?? props.driver; const entries = deriveProviderInstanceEntries(props.providers ?? []); const entry = entries.find((candidate) => candidate.instanceId === props.provider?.instanceId); @@ -138,11 +114,11 @@ export function SubagentTooltipContent(props: { {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel} - {effort || speed?.speedIcon ? ( + {effort || speedIcon ? ( {effort ? " · " : null} - {speed?.speedIcon && provider ? ( - + {speedIcon && provider ? ( + ) : null} {effort} diff --git a/apps/web/src/components/chat/TraitsSpeed.tsx b/apps/web/src/components/chat/TraitsSpeed.tsx index 77b242827bf1c..e91d9c36acb73 100644 --- a/apps/web/src/components/chat/TraitsSpeed.tsx +++ b/apps/web/src/components/chat/TraitsSpeed.tsx @@ -1,43 +1,9 @@ -import type { ProviderDriverKind, ProviderOptionDescriptor } from "@t3tools/contracts"; -import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; +import type { ProviderDriverKind } from "@t3tools/contracts"; import { ZapIcon } from "lucide-react"; import { UltrafastIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; -export function getTraitsSpeedDisplay( - descriptor: ProviderOptionDescriptor, -): { label: string; speedIcon: "fast" | "ultrafast" | null } | null { - if (descriptor.id === "fastMode" && descriptor.type === "boolean") { - return { - label: descriptor.currentValue === true ? "Fast" : "Normal", - speedIcon: descriptor.currentValue === true ? "fast" : null, - }; - } - if (descriptor.id !== "serviceTier" || descriptor.type !== "select") { - return null; - } - const currentValue = getProviderOptionCurrentValue(descriptor); - const fastTier = descriptor.options.find(({ label }) => label === "Fast"); - const ultrafastTier = descriptor.options.find(({ label }) => label === "Ultrafast"); - if ( - ((fastTier || ultrafastTier) && currentValue === "default") || - (fastTier && currentValue === fastTier.id) || - (ultrafastTier && currentValue === ultrafastTier.id) - ) { - return { - label: descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal", - speedIcon: - ultrafastTier && currentValue === ultrafastTier.id - ? "ultrafast" - : fastTier && currentValue === fastTier.id - ? "fast" - : null, - }; - } - return null; -} - export function TraitsSpeedIcon({ provider, speedIcon, diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 6e84acf10fc0d..01dfc3ee8392f 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -186,3 +186,22 @@ export function buildTraitsTriggerDisplay(input: { } return { label: labels.join(" · "), speedLabel, ultracode }; } + +/** The speed a saved selection runs at, read through the model's catalog descriptors. */ +export function getSelectionSpeedIcon( + descriptors: ReadonlyArray, + selection: ModelSelection | undefined, +): "fast" | "ultrafast" | null { + for (const descriptor of descriptors) { + const saved = selection?.options?.find((option) => option.id === descriptor.id)?.value; + if (descriptor.id === "fastMode" && descriptor.type === "boolean" && saved === true) { + return "fast"; + } + if (descriptor.id === "serviceTier" && descriptor.type === "select") { + const label = descriptor.options.find((option) => option.id === saved)?.label; + if (label === "Ultrafast") return "ultrafast"; + if (label === "Fast") return "fast"; + } + } + return null; +}