authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-06 21:53:52+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 11:53:52-07:00
log29366ea29abf4ceb41ab250d06e190a22258bd18
treee80db9d0e4bad419537ba4216a13ac9be85c3d3d
parent9503155da67add4fbd7fbc51c99ec19a0c8c4a2c
signature Signed by PGP key B5690EEEBB952194

fix(web): show subagent effort and speed in hover cards (#13056)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

6 files changed, 374 insertions(+), 11 deletions(-)

apps/web/src/components/Icons.tsx+7
......@@ -2,6 +2,13 @@ import React, { type SVGProps, useId } from "react";
22import { cn } from "~/lib/utils";
33export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
5export const UltrafastIcon: Icon = (props) => (
6 <svg viewBox="0 0 24 24" fill="currentColor" {...props}>
7 <path d="m17 2-10 12h7l-1 8 10-12h-7l1-8Z" opacity="0.4" />
8 <path d="m11 2-10 12h7l-1 8 10-12h-7l1-8Z" />
9 </svg>
10);
11
512export const FinderIcon: Icon = (props) => (
613 <svg viewBox="0 0 24 24" fill="none" {...props}>
714 <rect x="2" y="2" width="20" height="20" rx="4" fill="#36A9F5" />
apps/web/src/components/chat/SubagentTooltipContent.tsx+75-3
......@@ -4,11 +4,15 @@ import type {
44 OrchestrationProjectShell,
55 ServerProvider,
66 ProviderDriverKind,
7 ProviderInstanceId,
8 OrchestrationV2Subagent,
79} from "@t3tools/contracts";
810import {
911 resolveSubagentMetadata,
1012 subagentDetailPreview,
1113} from "@t3tools/client-runtime/state/subagent-display";
14import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model";
15import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed";
1216import type { ReactNode } from "react";
1317import {
1418 BotIcon,
......@@ -29,13 +33,17 @@ import { deriveProviderInstanceEntries, shouldShowInstanceBadge } from "../../pr
2933export function SubagentTooltipContent(props: {
3034 title: string;
3135 model: string | null;
36 providerInstanceId: ProviderInstanceId;
37 origin: OrchestrationV2Subagent["origin"];
3238 provider?: ServerProvider | undefined;
3339 /** The environment's instances; with several accounts on one provider, the card names this one. */
3440 providers?: ReadonlyArray<ServerProvider> | undefined;
3541 driver?: ProviderDriverKind | undefined;
3642 elapsed?: ReactNode;
3743 parentThread?: Pick<OrchestrationV2ThreadShell, "projectId" | "worktreePath"> | undefined;
38 childThread?: Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath"> | undefined;
44 childThread?:
45 | Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath" | "modelSelection">
46 | undefined;
3947 parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | undefined;
4048 childProject?: Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot"> | undefined;
4149 status: OrchestrationV2TurnItemStatus;
......@@ -44,6 +52,59 @@ export function SubagentTooltipContent(props: {
4452}) {
4553 const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props);
4654 const preview = subagentDetailPreview(props);
55 const model = props.model?.trim();
56 const modelSlug = props.provider
57 ? resolveSelectableModel(props.provider.driver, model, props.provider.models)
58 : model;
59 const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug);
60 const childSelection = props.childThread?.modelSelection;
61 const childModel = props.provider
62 ? (resolveSelectableModel(
63 props.provider.driver,
64 childSelection?.model,
65 props.provider.models,
66 ) ?? childSelection?.model.trim())
67 : childSelection?.model.trim();
68 const provider = props.provider;
69 const matchingSelection =
70 props.origin === "app_owned" &&
71 childModel === (modelSlug ?? model) &&
72 childSelection?.instanceId === props.providerInstanceId
73 ? childSelection
74 : undefined;
75 const effort = ["reasoningEffort", "effort", "reasoning", "variant"]
76 .map((id) => getModelSelectionStringOptionValue(matchingSelection, id))
77 .find(Boolean);
78 const speed = provider
79 ? providerModel?.capabilities?.optionDescriptors
80 ?.map((descriptor) => {
81 const saved = matchingSelection?.options?.find((option) => option.id === descriptor.id);
82 if (
83 descriptor.id === "fastMode" &&
84 descriptor.type === "boolean" &&
85 typeof saved?.value === "boolean"
86 ) {
87 return getTraitsSpeedDisplay(provider.driver, {
88 ...descriptor,
89 currentValue: saved.value,
90 });
91 }
92 if (
93 provider.driver === "codex" &&
94 descriptor.id === "serviceTier" &&
95 descriptor.type === "select" &&
96 typeof saved?.value === "string" &&
97 descriptor.options.some((option) => option.id === saved.value)
98 ) {
99 return getTraitsSpeedDisplay(provider.driver, {
100 ...descriptor,
101 currentValue: saved.value,
102 });
103 }
104 return null;
105 })
106 .find(Boolean)
107 : undefined;
47108 const driver = props.provider?.driver ?? props.driver;
48109 const entries = deriveProviderInstanceEntries(props.providers ?? []);
49110 const entry = entries.find((candidate) => candidate.instanceId === props.provider?.instanceId);
......@@ -75,8 +136,19 @@ export function SubagentTooltipContent(props: {
75136 ) : (
76137 <BotIcon className="size-3 shrink-0" />
77138 )}
78 <span className="min-w-0 truncate text-foreground/75">
79 {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel}
139 <span className="inline-flex min-w-0 items-center gap-1 text-foreground/75">
140 <span className="min-w-0 truncate">
141 {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel}
142 </span>
143 {effort || speed?.speedIcon ? (
144 <span className="inline-flex shrink-0 items-center gap-1">
145 {effort ? " · " : null}
146 {speed?.speedIcon && provider ? (
147 <TraitsSpeedIcon provider={provider.driver} speedIcon={speed.speedIcon} size="xs" />
148 ) : null}
149 {effort}
150 </span>
151 ) : null}
80152 </span>
81153 </div>
82154 <div className="flex min-w-0 items-center justify-between gap-4">
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+210-8
......@@ -1,6 +1,12 @@
11import { act, cloneElement, type ReactElement, type ReactNode } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { EnvironmentId, ThreadId, type OrchestrationV2ContextTransfer } from "@t3tools/contracts";
2import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
3import {
4 EnvironmentId,
5 ThreadId,
6 type ModelSelection,
7 type ProviderOptionDescriptor,
8 type OrchestrationV2ContextTransfer,
9} from "@t3tools/contracts";
410import * as DateTime from "effect/DateTime";
511import { afterEach, expect, it, vi } from "vite-plus/test";
612
......@@ -196,7 +202,11 @@ it("shows readable models and only differing workspace details in agent tooltips
196202 worktreePath: null as string | null,
197203 branch: null as string | null,
198204 title: "Worker",
199 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
205 modelSelection: {
206 instanceId: "codex",
207 model: "gpt-5.4",
208 options: [{ id: "reasoningEffort", value: "high" }] as ModelSelection["options"],
209 },
200210 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
201211 };
202212 state.projects = [
......@@ -231,6 +241,7 @@ it("shows readable models and only differing workspace details in agent tooltips
231241 {
232242 id: "agent",
233243 childThreadId: "child",
244 origin: "app_owned",
234245 driver: "codex",
235246 providerInstanceId: "codex",
236247 title: "Worker",
......@@ -253,12 +264,24 @@ it("shows readable models and only differing workspace details in agent tooltips
253264 await act(async () => {
254265 renderer = create(panel);
255266 });
256 const text = () =>
257 renderer.root
258 .findAll((node) => typeof node.type === "string")
259 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
260 .join("");
267 const text = (visibleOnly = false) => {
268 const read = (node: ReactTestInstance | string): string => {
269 if (typeof node === "string") return node;
270 if (visibleOnly && node.props.className === "sr-only") return "";
271 return node.children.map(read).join("");
272 };
273 return read(renderer.root);
274 };
275 expect(text()).toContain("My GPT · high");
276 state.projection = {
277 ...projection,
278 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
279 };
280 await act(async () => renderer.update(cloneElement(panel)));
261281 expect(text()).toContain("My GPT");
282 expect(text()).not.toContain("My GPT · high");
283 state.projection = projection;
284 await act(async () => renderer.update(cloneElement(panel)));
262285 expect(text()).not.toContain("Tokens");
263286 expect(text()).not.toContain("Open subagent");
264287 expect(text()).not.toContain("Project");
......@@ -279,6 +302,11 @@ it("shows readable models and only differing workspace details in agent tooltips
279302 };
280303 await act(async () => renderer.update(cloneElement(panel)));
281304 expect(text()).toContain(expected);
305 if (!model?.trim() || model === "gpt-5.5" || model === "custom/model-v1") {
306 expect(text()).not.toContain(" · high");
307 } else {
308 expect(text()).toContain(`${expected} · high`);
309 }
282310 expect(text()).not.toContain("Unknown");
283311 if (!model?.trim()) expect(text()).not.toContain("My GPT");
284312 }
......@@ -302,6 +330,179 @@ it("shows readable models and only differing workspace details in agent tooltips
302330 expect(text()).not.toContain("My GPT");
303331 }
304332
333 state.projection = projection;
334 for (const [options, expected] of [
335 [[{ id: "effort", value: "max" }], " · max"],
336 [[{ id: "reasoning", value: "low" }], " · low"],
337 [[{ id: "variant", value: "high" }], " · high"],
338 [[{ id: "reasoningEffort", value: "none" }], " · none"],
339 [[{ id: "reasoningEffort", value: true }], ""],
340 [[{ id: "serviceTier", value: "fast" }], ""],
341 [[], ""],
342 [undefined, ""],
343 ] as const) {
344 child.modelSelection.options = options;
345 state.shells = [{ environmentId: "test", source: { ...child } }];
346 await act(async () => renderer.update(cloneElement(panel)));
347 expect(text()).toContain(`My GPT${expected}`);
348 if (!expected) expect(text()).not.toContain("My GPT ·");
349 }
350 const speedConfig = state.configs.get("test");
351 const serviceTier: ProviderOptionDescriptor = {
352 id: "serviceTier",
353 label: "Service Tier",
354 type: "select",
355 currentValue: "priority",
356 options: [
357 { id: "default", label: "Standard", isDefault: true },
358 { id: "priority", label: "Fast" },
359 { id: "ultrafast", label: "Ultrafast" },
360 { id: "flex", label: "Flex" },
361 ],
362 };
363 const fastMode: ProviderOptionDescriptor = {
364 id: "fastMode",
365 label: "Fast Mode",
366 type: "boolean",
367 currentValue: true,
368 };
369 for (const [driver, descriptor, value, iconLabel] of [
370 ["codex", serviceTier, "default", ""],
371 ["codex", serviceTier, "priority", "Fast mode on"],
372 ["codex", serviceTier, "ultrafast", "Ultrafast mode on"],
373 ["codex", serviceTier, "flex", ""],
374 ["codex", serviceTier, "unknown", ""],
375 [
376 "codex",
377 { ...serviceTier, options: serviceTier.options.filter(({ id }) => id !== "ultrafast") },
378 "ultrafast",
379 "",
380 ],
381 ["codex", serviceTier, true, ""],
382 ["codex", serviceTier, undefined, ""],
383 ["claudeAgent", fastMode, true, "Fast mode on"],
384 ["claudeAgent", fastMode, false, ""],
385 ["cursor", fastMode, true, "Fast mode on"],
386 ["cursor", fastMode, false, ""],
387 ["opencode", fastMode, true, "Fast mode on"],
388 ["cursor", fastMode, "true", ""],
389 ["cursor", serviceTier, "priority", ""],
390 ] as const) {
391 state.configs.set("test", {
392 providers: [
393 {
394 instanceId: "codex",
395 driver,
396 models: [
397 {
398 slug: "gpt-5.4",
399 name: "My GPT",
400 capabilities: { optionDescriptors: [descriptor] },
401 },
402 ],
403 },
404 ],
405 });
406 child.modelSelection.options = [
407 { id: "reasoningEffort", value: "high" },
408 ...(value === undefined ? [] : [{ id: descriptor.id, value }]),
409 ];
410 state.shells = [{ environmentId: "test", source: { ...child } }];
411 await act(async () => renderer.update(cloneElement(panel)));
412 expect(text(true)).toContain("My GPT · high");
413 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
414 expect(text()).toContain(`My GPT · ${iconLabel}high`);
415 if (!iconLabel) expect(text()).not.toContain("mode on");
416 child.modelSelection.options = child.modelSelection.options.filter(
417 ({ id }) => id !== "reasoningEffort",
418 );
419 state.shells = [{ environmentId: "test", source: { ...child } }];
420 await act(async () => renderer.update(cloneElement(panel)));
421 expect(text()).not.toContain(" · high");
422 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
423 if (iconLabel) expect(text()).toContain(iconLabel);
424 else expect(text()).not.toContain("mode on");
425 state.projection = {
426 ...projection,
427 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
428 };
429 await act(async () => renderer.update(cloneElement(panel)));
430 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
431 state.projection = projection;
432 for (const modelSelection of [
433 { ...child.modelSelection, instanceId: "other" },
434 { ...child.modelSelection, model: "gpt-5.5" },
435 ]) {
436 state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }];
437 await act(async () => renderer.update(cloneElement(panel)));
438 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
439 }
440 }
441 state.configs.set("test", {
442 providers: [
443 {
444 instanceId: "codex",
445 driver: "codex",
446 displayName: "Work account",
447 models: [
448 {
449 slug: "gpt-5.4",
450 name: "My GPT",
451 capabilities: { optionDescriptors: [serviceTier] },
452 },
453 ],
454 },
455 {
456 instanceId: "codex_personal",
457 driver: "codex",
458 displayName: "Personal account",
459 models: [],
460 },
461 ],
462 });
463 child.modelSelection.options = [
464 { id: "reasoningEffort", value: "high" },
465 { id: "serviceTier", value: "priority" },
466 ];
467 state.shells = [{ environmentId: "test", source: { ...child } }];
468 await act(async () => renderer.update(cloneElement(panel)));
469 expect(text(true)).toContain("My GPT · Work account · high");
470 expect(text()).toContain("My GPT · Work account · Fast mode onhigh");
471 expect(text()).not.toContain("Personal account");
472 state.configs.set("test", speedConfig);
473 child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }];
474 state.shells = [
475 {
476 environmentId: "test",
477 source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } },
478 },
479 ];
480 await act(async () => renderer.update(cloneElement(panel)));
481 expect(text()).not.toContain("My GPT ·");
482 const config = state.configs.get("test");
483 state.configs.clear();
484 await act(async () => renderer.update(cloneElement(panel)));
485 expect(text()).toContain("GPT-5.4");
486 expect(text()).not.toContain("GPT-5.4 ·");
487 state.shells = [{ environmentId: "test", source: { ...child } }];
488 await act(async () => renderer.update(cloneElement(panel)));
489 expect(text()).toContain("GPT-5.4 · high");
490 state.configs.set("test", config);
491 for (const model of ["custom/model-v1", "custom/model-v2"]) {
492 state.projection = {
493 ...projection,
494 subagents: [{ ...projection.subagents[0], model }],
495 };
496 state.shells = [
497 {
498 environmentId: "test",
499 source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } },
500 },
501 ];
502 await act(async () => renderer.update(cloneElement(panel)));
503 expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1");
504 }
505
305506 state.projection = {
306507 ...projection,
307508 subagents: [
......@@ -364,6 +565,7 @@ it("shows readable models and only differing workspace details in agent tooltips
364565 state.configs.clear();
365566 await act(async () => renderer.update(cloneElement(panel)));
366567 expect(text()).toContain("GPT-5.4");
568 expect(text()).not.toContain("GPT-5.4 ·");
367569 expect(text()).not.toContain("gpt-5.4");
368570 expect(text()).not.toContain("Project");
369571 expect(text()).not.toContain("Workspace");
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+3
......@@ -208,6 +208,7 @@ export function ThreadRelationshipsPanel(props: {
208208 ...projectedSubagentsToRuntime([subagent])[0]!,
209209 driver: subagent.driver,
210210 providerInstanceId: subagent.providerInstanceId,
211 origin: subagent.origin,
211212 },
212213 ]),
213214 ),
......@@ -383,6 +384,8 @@ export function ThreadRelationshipsPanel(props: {
383384 <SubagentTooltipContent
384385 title={threadTitle}
385386 model={agent.model}
387 providerInstanceId={agent.providerInstanceId}
388 origin={agent.origin}
386389 provider={provider}
387390 providers={providers}
388391 driver={providerDriver}
apps/web/src/components/chat/TraitsSpeed.tsx created+70
......@@ -0,0 +1,70 @@
1import type { ProviderDriverKind, ProviderOptionDescriptor } from "@t3tools/contracts";
2import { getProviderOptionCurrentValue } from "@t3tools/shared/model";
3import { ZapIcon } from "lucide-react";
4import { UltrafastIcon } from "../Icons";
5import { cn } from "~/lib/utils";
6import { ComposerControlIcon, type ComposerControlSize } from "./ComposerControl";
7
8export function getTraitsSpeedDisplay(
9 provider: ProviderDriverKind,
10 descriptor: ProviderOptionDescriptor,
11): { label: string; speedIcon: "fast" | "ultrafast" | null } | null {
12 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
13 return {
14 label: descriptor.currentValue === true ? "Fast" : "Normal",
15 speedIcon: descriptor.currentValue === true ? "fast" : null,
16 };
17 }
18 if (provider !== "codex" || descriptor.id !== "serviceTier" || descriptor.type !== "select") {
19 return null;
20 }
21 const currentValue = getProviderOptionCurrentValue(descriptor);
22 const fastTier = descriptor.options.find(({ label }) => label === "Fast");
23 const ultrafastTier = descriptor.options.find(({ label }) => label === "Ultrafast");
24 if (
25 ((fastTier || ultrafastTier) && currentValue === "default") ||
26 (fastTier && currentValue === fastTier.id) ||
27 (ultrafastTier && currentValue === ultrafastTier.id)
28 ) {
29 return {
30 label: descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal",
31 speedIcon:
32 ultrafastTier && currentValue === ultrafastTier.id
33 ? "ultrafast"
34 : fastTier && currentValue === fastTier.id
35 ? "fast"
36 : null,
37 };
38 }
39 return null;
40}
41
42export function TraitsSpeedIcon({
43 provider,
44 speedIcon,
45 size = "sm",
46}: {
47 provider: ProviderDriverKind;
48 speedIcon: "fast" | "ultrafast";
49 size?: ComposerControlSize;
50}) {
51 return (
52 <>
53 <ComposerControlIcon
54 icon={speedIcon === "ultrafast" ? UltrafastIcon : ZapIcon}
55 size={size}
56 className={cn(
57 "fill-current opacity-80",
58 size === "xs"
59 ? "text-current"
60 : provider === "claudeAgent"
61 ? "text-[#d97757]"
62 : "text-foreground",
63 )}
64 />
65 <span className="sr-only">
66 {speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"}
67 </span>
68 </>
69 );
70}
apps/web/src/components/chat/V2LifecycleRow.tsx+9
......@@ -17,6 +17,7 @@ import {
1717 ProviderDriverKind,
1818 type OrchestrationV2Notification,
1919 type OrchestrationV2TurnItem,
20 type OrchestrationV2Subagent,
2021 type ProviderInstanceId,
2122 type ServerProvider,
2223 type ThreadId,
......@@ -212,6 +213,8 @@ export function V2LifecycleRow(props: {
212213 <SubagentTimelineLink
213214 parentRef={scopeThreadRef(props.environmentId, item.threadId)}
214215 subagentId={item.subagentId}
216 providerInstanceId={item.providerInstanceId}
217 origin={item.origin}
215218 status={item.status}
216219 driver={item.driver}
217220 provider={props.providerStatuses.find(
......@@ -370,6 +373,8 @@ export function SubagentNotificationLink(props: {
370373 <SubagentTimelineLink
371374 parentRef={props.parentRef}
372375 subagentId={agent.id}
376 providerInstanceId={agent.providerInstanceId}
377 origin={agent.origin}
373378 status={agent.status}
374379 driver={agent.driver}
375380 provider={props.providerStatuses.find(
......@@ -395,6 +400,8 @@ export function SubagentNotificationLink(props: {
395400function SubagentTimelineLink(props: {
396401 readonly parentRef: ScopedThreadRef;
397402 readonly subagentId: NodeId;
403 readonly providerInstanceId: ProviderInstanceId;
404 readonly origin: OrchestrationV2Subagent["origin"];
398405 readonly driver: ProviderDriverKind;
399406 readonly provider: ServerProvider | undefined;
400407 readonly providers: ReadonlyArray<ServerProvider>;
......@@ -540,6 +547,8 @@ function SubagentTimelineTooltip(
540547 <SubagentTooltipContent
541548 title={formatSubagentDisplayTitle(child?.title ?? props.title)}
542549 model={props.model}
550 providerInstanceId={props.providerInstanceId}
551 origin={props.origin}
543552 provider={props.provider}
544553 providers={props.providers}
545554 driver={props.driver}