From 9f74d44399c84344c57a4a9a6c9b790feeb5b11a Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Thu, 29 Oct 2026 00:10:50 -0400 Subject: [PATCH] feat(web): gauge option for the composer's effort display Effort display gains a third choice beside Word and Bars: Lucide's gauge, with the needle at the selected level and the arc filled up to it. The needle sweeps left to right across the levels the bars count, and an off level rests it at the arc's start with no fill. --- .../src/components/chat/EffortGlyph.test.ts | 10 +++- apps/web/src/components/chat/EffortGlyph.tsx | 46 +++++++++++++++++++ apps/web/src/components/chat/TraitsPicker.tsx | 10 ++-- .../settings/AppearanceSettingsPanel.tsx | 6 ++- .../src/components/settings/settingsSearch.ts | 2 +- packages/contracts/src/settings.ts | 4 +- 6 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..3382d32fa26e6 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { effortBarHeights, resolveEffortGlyph } from "./EffortGlyph"; +import { effortBarHeights, effortGaugeAngle, resolveEffortGlyph } from "./EffortGlyph"; const descriptor = (ids: ReadonlyArray, promptInjectedValues?: ReadonlyArray) => ({ id: "effort", @@ -65,3 +65,11 @@ describe("effortBarHeights", () => { }, ); }); + +describe("effortGaugeAngle", () => { + it("sweeps the needle from left to right and rests off at the arc's start", () => { + expect([1, 2, 3, 4].map((filled) => effortGaugeAngle(4, filled))).toEqual([180, 120, 60, 0]); + expect(effortGaugeAngle(4, 0)).toBe(210); + expect(effortGaugeAngle(1, 1)).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 2e033c6caa8da..4343522341622 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -1,5 +1,7 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { cn } from "~/lib/utils"; + import type { ComposerControlSize } from "./ComposerControl"; const GLYPH_HEIGHT_SM = 14; @@ -84,3 +86,47 @@ export function EffortGlyph(props: { count: number; filled: number; size?: Compo ); } + +// Lucide's gauge: a 10-unit arc around (12, 14) from 210° to -30°, and a 5.66-unit needle. +const GAUGE_ARC_START = "M3.34 19"; +const GAUGE_RADIUS = 10; +const GAUGE_NEEDLE_LENGTH = 5.66; +const GAUGE_OFF_ANGLE = 210; + +/** The needle sweeps left to right across the levels; off rests it at the arc's start. */ +export function effortGaugeAngle(count: number, filled: number): number { + if (filled === 0) return GAUGE_OFF_ANGLE; + return count === 1 ? 0 : 180 - (180 * (filled - 1)) / (count - 1); +} + +function gaugePoint(angle: number, radius: number): string { + const radians = (angle * Math.PI) / 180; + const x = 12 + radius * Math.cos(radians); + const y = 14 - radius * Math.sin(radians); + return `${x.toFixed(2)} ${y.toFixed(2)}`; +} + +/** Lucide's gauge with the needle at the current level and the arc filled up to it. */ +export function EffortGauge(props: { count: number; filled: number; size?: ComposerControlSize }) { + const angle = effortGaugeAngle(props.count, props.filled); + const largeArc = GAUGE_OFF_ANGLE - angle > 180 ? 1 : 0; + return ( + + ); +} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 79a29d293dc49..a9ed3c6d3e2d3 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -43,7 +43,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerControl, ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; import { focusRequestedPermission, useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; -import { EffortGlyph, resolveEffortGlyph } from "./EffortGlyph"; +import { EffortGauge, EffortGlyph, resolveEffortGlyph } from "./EffortGlyph"; type ProviderOptions = ReadonlyArray; @@ -319,7 +319,7 @@ interface TraitsMenuContentProps { planModeEnabled: boolean; triggerClassName?: string; isComposerOwned?: boolean; - /** Composer chips name the effort level unless the user picked bars. */ + /** Composer chips name the effort level unless the user picked bars or the gauge. */ effortDisplay?: EffortDisplay; /** Extra section shown below the traits in the picker popup (Permissions). */ extraMenuContent?: ReactNode; @@ -668,7 +668,11 @@ export const TraitsPicker = memo(function TraitsPicker({ {effortGlyph ? ( {effortWord === null ? ( - + effortDisplay === "gauge" ? ( + + ) : ( + + ) ) : null} {effortWord !== null ? ( <> diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 1c5812a2bda0d..ce920d7bed3b8 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -81,6 +81,7 @@ const CHAT_WIDTH_LABELS: Record = { const EFFORT_DISPLAY_LABELS: Record = { label: "Word", bars: "Bars", + gauge: "Gauge", }; export function AppearanceSettingsPanel() { @@ -277,7 +278,7 @@ export function AppearanceSettingsPanel() { { - if (value === "bars" || value === "label") + if (value === "bars" || value === "gauge" || value === "label") updateSettings({ effortDisplay: value }); }} > @@ -303,6 +304,7 @@ export function AppearanceSettingsPanel() { Word (default) Bars + Gauge diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index dc494caf5344d..b20ed85db525f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -337,7 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "effort-display", title: "Effort display", to: "/settings/appearance", - searchTerms: ["reasoning effort thinking bars graph word label level composer"], + searchTerms: ["reasoning effort thinking bars graph gauge word label level composer"], }, { id: "math-rendering", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 179e58b7a0b56..93517c5869a69 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -326,8 +326,8 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; -/** How the composer shows reasoning effort: the level's name, or rising bars. */ -export const EffortDisplay = Schema.Literals(["label", "bars"]); +/** How the composer shows reasoning effort: the level's name, rising bars, or a gauge. */ +export const EffortDisplay = Schema.Literals(["label", "bars", "gauge"]); export type EffortDisplay = typeof EffortDisplay.Type; export const ModelFavorite = Schema.Struct({