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
82 changes: 77 additions & 5 deletions apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,14 +18,22 @@ 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,
| "threadId"
| "childThreadId"
| "model"
| "modelSelection"
| "origin"
| "driver"
| "providerInstanceId"
| "title"
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<View className="min-w-0 flex-row items-center gap-1.5">
{showBadge ? (
Expand All @@ -166,8 +180,8 @@ function SubagentMetadata(props: {
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""}
{modelLabel}
{effort ? ` · ${effort}` : ""}
</Text>
<SubagentTraitsLabel traits={traits} />
{workspace.map(({ label, value }) => (
// Preserve the label against flattening without making it a separate accessibility stop.
<View
Expand All @@ -190,3 +204,61 @@ function SubagentMetadata(props: {
</View>
);
}

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 (
<View
accessible
collapsable={false}
accessibilityLabel={[speedLabel, effort].filter(Boolean).join(", ")}
className="min-w-0 shrink flex-row items-center gap-1"
>
<Text
accessibilityElementsHidden
importantForAccessibility="no"
className="shrink-0 text-xs text-foreground-muted"
>
·
</Text>
{speedIcon ? <SpeedIcon speedIcon={speedIcon} /> : null}
{effort ? (
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{effort}
</Text>
) : null}
</View>
);
}

// Same glyphs as the web composer's speed icons.
function SpeedIcon(props: { readonly speedIcon: "fast" | "ultrafast" }) {
return (
<Svg width={11} height={11} viewBox="0 0 24 24" opacity={0.8}>
{props.speedIcon === "ultrafast" ? (
<>
<ThemedPath
d="m17 2-10 12h7l-1 8 10-12h-7l1-8Z"
colorClassName="accent-foreground-muted"
fill="currentColor"
opacity={0.4}
/>
<ThemedPath
d="m11 2-10 12h7l-1 8 10-12h-7l1-8Z"
colorClassName="accent-foreground-muted"
fill="currentColor"
/>
</>
) : (
<ThemedPath
d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"
colorClassName="accent-foreground-muted"
fill="currentColor"
/>
)}
</Svg>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
import {
NodeId,
ProviderDriverKind,
ProviderInstanceId,
RunId,
ThreadId,
type ModelSelection,
type OrchestrationV2SubagentWorkflow,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand All @@ -16,6 +18,8 @@ import {
nextWorkflowSelection,
resolveSubagentRowPresentation,
resolveSubagentStopTarget,
resolveSubagentTraits,
subagentModelSelection,
workflowMemberDetail,
workflowMemberMetadata,
workflowRail,
Expand Down Expand Up @@ -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> = {}): 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([
Expand Down
51 changes: 50 additions & 1 deletion apps/mobile/src/features/threads/threadAgentsPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<OrchestrationV2Subagent, "origin" | "modelSelection">,
childThread: Pick<OrchestrationV2ThreadShell, "modelSelection"> | 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<ServerProvider, "driver" | "models"> | 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 {
Expand Down
42 changes: 9 additions & 33 deletions apps/web/src/components/chat/SubagentTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -138,11 +114,11 @@ export function SubagentTooltipContent(props: {
<span className="min-w-0 truncate">
{showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel}
</span>
{effort || speed?.speedIcon ? (
{effort || speedIcon ? (
<span className="inline-flex shrink-0 items-center gap-1">
{effort ? " · " : null}
{speed?.speedIcon && provider ? (
<TraitsSpeedIcon provider={provider.driver} speedIcon={speed.speedIcon} size="xs" />
{speedIcon && provider ? (
<TraitsSpeedIcon provider={provider.driver} speedIcon={speedIcon} size="xs" />
) : null}
{effort}
</span>
Expand Down
Loading
Loading