From 29366ea29abf4ceb41ab250d06e190a22258bd18 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:53:52 +0300 Subject: [PATCH] fix(web): show subagent effort and speed in hover cards (#13056) Co-authored-by: Julius Marminge --- apps/web/src/components/Icons.tsx | 7 + .../chat/SubagentTooltipContent.tsx | 78 ++++++- ...ThreadRelationshipsControl.agents.test.tsx | 218 +++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 3 + apps/web/src/components/chat/TraitsSpeed.tsx | 70 ++++++ .../src/components/chat/V2LifecycleRow.tsx | 9 + 6 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/TraitsSpeed.tsx diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index f47bd48678abb7ac4206816e57c393e4a1c794b1..4c62f5f385ef14521f89b7105725ec609e4708fb 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,6 +2,13 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; +export const UltrafastIcon: Icon = (props) => ( + + + + +); + export const FinderIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 11a654482541f2d45e38bd49302989bf685273e0..61408d61f639f834479c232189a604dc4c8b18be 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -4,11 +4,15 @@ import type { OrchestrationProjectShell, ServerProvider, ProviderDriverKind, + ProviderInstanceId, + OrchestrationV2Subagent, } from "@t3tools/contracts"; import { resolveSubagentMetadata, subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; +import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; +import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; import type { ReactNode } from "react"; import { BotIcon, @@ -29,13 +33,17 @@ import { deriveProviderInstanceEntries, shouldShowInstanceBadge } from "../../pr export function SubagentTooltipContent(props: { title: string; model: string | null; + providerInstanceId: ProviderInstanceId; + origin: OrchestrationV2Subagent["origin"]; provider?: ServerProvider | undefined; /** The environment's instances; with several accounts on one provider, the card names this one. */ providers?: ReadonlyArray | undefined; driver?: ProviderDriverKind | undefined; elapsed?: ReactNode; parentThread?: Pick | undefined; - childThread?: Pick | undefined; + childThread?: + | Pick + | undefined; parentProject?: Pick | undefined; childProject?: Pick | undefined; status: OrchestrationV2TurnItemStatus; @@ -44,6 +52,59 @@ export function SubagentTooltipContent(props: { }) { const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); const preview = subagentDetailPreview(props); + const model = props.model?.trim(); + const modelSlug = props.provider + ? resolveSelectableModel(props.provider.driver, model, props.provider.models) + : model; + const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); + const childSelection = props.childThread?.modelSelection; + const childModel = props.provider + ? (resolveSelectableModel( + props.provider.driver, + childSelection?.model, + props.provider.models, + ) ?? childSelection?.model.trim()) + : childSelection?.model.trim(); + const provider = props.provider; + const matchingSelection = + props.origin === "app_owned" && + childModel === (modelSlug ?? model) && + childSelection?.instanceId === props.providerInstanceId + ? childSelection + : undefined; + const effort = ["reasoningEffort", "effort", "reasoning", "variant"] + .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(provider.driver, { + ...descriptor, + currentValue: saved.value, + }); + } + if ( + provider.driver === "codex" && + descriptor.id === "serviceTier" && + descriptor.type === "select" && + typeof saved?.value === "string" && + descriptor.options.some((option) => option.id === saved.value) + ) { + return getTraitsSpeedDisplay(provider.driver, { + ...descriptor, + currentValue: saved.value, + }); + } + return null; + }) + .find(Boolean) + : undefined; const driver = props.provider?.driver ?? props.driver; const entries = deriveProviderInstanceEntries(props.providers ?? []); const entry = entries.find((candidate) => candidate.instanceId === props.provider?.instanceId); @@ -75,8 +136,19 @@ export function SubagentTooltipContent(props: { ) : ( )} - - {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel} + + + {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel} + + {effort || speed?.speedIcon ? ( + + {effort ? " · " : null} + {speed?.speedIcon && provider ? ( + + ) : null} + {effort} + + ) : null}
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index 333bf325a2910cf5e4c4058a0a391f793fccb7d3..af1d42f3876b4367020f07a05047cd23927671d6 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -1,6 +1,12 @@ import { act, cloneElement, type ReactElement, type ReactNode } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { EnvironmentId, ThreadId, type OrchestrationV2ContextTransfer } from "@t3tools/contracts"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { + EnvironmentId, + ThreadId, + type ModelSelection, + type ProviderOptionDescriptor, + type OrchestrationV2ContextTransfer, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -196,7 +202,11 @@ it("shows readable models and only differing workspace details in agent tooltips worktreePath: null as string | null, branch: null as string | null, title: "Worker", - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }] as ModelSelection["options"], + }, lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, }; state.projects = [ @@ -231,6 +241,7 @@ it("shows readable models and only differing workspace details in agent tooltips { id: "agent", childThreadId: "child", + origin: "app_owned", driver: "codex", providerInstanceId: "codex", title: "Worker", @@ -253,12 +264,24 @@ it("shows readable models and only differing workspace details in agent tooltips await act(async () => { renderer = create(panel); }); - const text = () => - renderer.root - .findAll((node) => typeof node.type === "string") - .flatMap((node) => node.children.filter((child) => typeof child === "string")) - .join(""); + const text = (visibleOnly = false) => { + const read = (node: ReactTestInstance | string): string => { + if (typeof node === "string") return node; + if (visibleOnly && node.props.className === "sr-only") return ""; + return node.children.map(read).join(""); + }; + return read(renderer.root); + }; + expect(text()).toContain("My GPT · high"); + state.projection = { + ...projection, + subagents: [{ ...projection.subagents[0], origin: "provider_native" }], + }; + await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("My GPT"); + expect(text()).not.toContain("My GPT · high"); + state.projection = projection; + await act(async () => renderer.update(cloneElement(panel))); expect(text()).not.toContain("Tokens"); expect(text()).not.toContain("Open subagent"); expect(text()).not.toContain("Project"); @@ -279,6 +302,11 @@ it("shows readable models and only differing workspace details in agent tooltips }; await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain(expected); + if (!model?.trim() || model === "gpt-5.5" || model === "custom/model-v1") { + expect(text()).not.toContain(" · high"); + } else { + expect(text()).toContain(`${expected} · high`); + } expect(text()).not.toContain("Unknown"); if (!model?.trim()) expect(text()).not.toContain("My GPT"); } @@ -302,6 +330,179 @@ it("shows readable models and only differing workspace details in agent tooltips expect(text()).not.toContain("My GPT"); } + state.projection = projection; + for (const [options, expected] of [ + [[{ id: "effort", value: "max" }], " · max"], + [[{ id: "reasoning", value: "low" }], " · low"], + [[{ id: "variant", value: "high" }], " · high"], + [[{ id: "reasoningEffort", value: "none" }], " · none"], + [[{ id: "reasoningEffort", value: true }], ""], + [[{ id: "serviceTier", value: "fast" }], ""], + [[], ""], + [undefined, ""], + ] as const) { + child.modelSelection.options = options; + state.shells = [{ environmentId: "test", source: { ...child } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain(`My GPT${expected}`); + if (!expected) expect(text()).not.toContain("My GPT ·"); + } + const speedConfig = state.configs.get("test"); + const serviceTier: ProviderOptionDescriptor = { + id: "serviceTier", + label: "Service Tier", + type: "select", + currentValue: "priority", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "ultrafast", label: "Ultrafast" }, + { id: "flex", label: "Flex" }, + ], + }; + const fastMode: ProviderOptionDescriptor = { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: true, + }; + for (const [driver, descriptor, value, iconLabel] of [ + ["codex", serviceTier, "default", ""], + ["codex", serviceTier, "priority", "Fast mode on"], + ["codex", serviceTier, "ultrafast", "Ultrafast mode on"], + ["codex", serviceTier, "flex", ""], + ["codex", serviceTier, "unknown", ""], + [ + "codex", + { ...serviceTier, options: serviceTier.options.filter(({ id }) => id !== "ultrafast") }, + "ultrafast", + "", + ], + ["codex", serviceTier, true, ""], + ["codex", serviceTier, undefined, ""], + ["claudeAgent", fastMode, true, "Fast mode on"], + ["claudeAgent", fastMode, false, ""], + ["cursor", fastMode, true, "Fast mode on"], + ["cursor", fastMode, false, ""], + ["opencode", fastMode, true, "Fast mode on"], + ["cursor", fastMode, "true", ""], + ["cursor", serviceTier, "priority", ""], + ] as const) { + state.configs.set("test", { + providers: [ + { + instanceId: "codex", + driver, + models: [ + { + slug: "gpt-5.4", + name: "My GPT", + capabilities: { optionDescriptors: [descriptor] }, + }, + ], + }, + ], + }); + child.modelSelection.options = [ + { id: "reasoningEffort", value: "high" }, + ...(value === undefined ? [] : [{ id: descriptor.id, value }]), + ]; + state.shells = [{ environmentId: "test", source: { ...child } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text(true)).toContain("My GPT · high"); + expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); + expect(text()).toContain(`My GPT · ${iconLabel}high`); + if (!iconLabel) expect(text()).not.toContain("mode on"); + child.modelSelection.options = child.modelSelection.options.filter( + ({ id }) => id !== "reasoningEffort", + ); + state.shells = [{ environmentId: "test", source: { ...child } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toContain(" · high"); + expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); + if (iconLabel) expect(text()).toContain(iconLabel); + else expect(text()).not.toContain("mode on"); + state.projection = { + ...projection, + subagents: [{ ...projection.subagents[0], origin: "provider_native" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + state.projection = projection; + for (const modelSelection of [ + { ...child.modelSelection, instanceId: "other" }, + { ...child.modelSelection, model: "gpt-5.5" }, + ]) { + state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + } + } + state.configs.set("test", { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Work account", + models: [ + { + slug: "gpt-5.4", + name: "My GPT", + capabilities: { optionDescriptors: [serviceTier] }, + }, + ], + }, + { + instanceId: "codex_personal", + driver: "codex", + displayName: "Personal account", + models: [], + }, + ], + }); + child.modelSelection.options = [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ]; + state.shells = [{ environmentId: "test", source: { ...child } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text(true)).toContain("My GPT · Work account · high"); + expect(text()).toContain("My GPT · Work account · Fast mode onhigh"); + expect(text()).not.toContain("Personal account"); + state.configs.set("test", speedConfig); + child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }]; + state.shells = [ + { + environmentId: "test", + source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }, + }, + ]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toContain("My GPT ·"); + const config = state.configs.get("test"); + state.configs.clear(); + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain("GPT-5.4"); + expect(text()).not.toContain("GPT-5.4 ·"); + state.shells = [{ environmentId: "test", source: { ...child } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain("GPT-5.4 · high"); + state.configs.set("test", config); + for (const model of ["custom/model-v1", "custom/model-v2"]) { + state.projection = { + ...projection, + subagents: [{ ...projection.subagents[0], model }], + }; + state.shells = [ + { + environmentId: "test", + source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } }, + }, + ]; + await act(async () => renderer.update(cloneElement(panel))); + expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1"); + } + state.projection = { ...projection, subagents: [ @@ -364,6 +565,7 @@ it("shows readable models and only differing workspace details in agent tooltips state.configs.clear(); await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("GPT-5.4"); + expect(text()).not.toContain("GPT-5.4 ·"); expect(text()).not.toContain("gpt-5.4"); expect(text()).not.toContain("Project"); expect(text()).not.toContain("Workspace"); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 93841dd1af7e037d0d966820c103b2b3d12c0e24..6d52368186997dc41b7d8688b47fe344ce113d9a 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -208,6 +208,7 @@ export function ThreadRelationshipsPanel(props: { ...projectedSubagentsToRuntime([subagent])[0]!, driver: subagent.driver, providerInstanceId: subagent.providerInstanceId, + origin: subagent.origin, }, ]), ), @@ -383,6 +384,8 @@ export function ThreadRelationshipsPanel(props: { 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, + size = "sm", +}: { + provider: ProviderDriverKind; + speedIcon: "fast" | "ultrafast"; + size?: ComposerControlSize; +}) { + return ( + <> + + + {speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"} + + + ); +} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index cdd469f7acb4902690c0a61ccd0b7d6447c0db69..faeeac04c393e4aca99357cc84df768b1725737c 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -17,6 +17,7 @@ import { ProviderDriverKind, type OrchestrationV2Notification, type OrchestrationV2TurnItem, + type OrchestrationV2Subagent, type ProviderInstanceId, type ServerProvider, type ThreadId, @@ -212,6 +213,8 @@ export function V2LifecycleRow(props: { ; @@ -540,6 +547,8 @@ function SubagentTimelineTooltip(