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({