-
Notifications
You must be signed in to change notification settings - Fork 113
🤖 feat: add Fast/Slow service-tier control to chat input #3476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ammar-agent
wants to merge
5
commits into
main
Choose a base branch
from
chat-input-f8xm
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
23d1497
🤖 feat: add Fast/Slow service-tier control and /fast /slow one-shots
ammar-agent 8c7fdf9
fix: restrict service tier to direct/passthrough OpenAI routes
ammar-agent 615aa48
fix: carry creation-time service tier into new workspace
ammar-agent 6f75162
refactor: drop /fast /slow one-shots, keep bolt service-tier control
ammar-agent c180454
fix: compose per-chat service tier with /<model> one-shots
ammar-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
114 changes: 114 additions & 0 deletions
114
src/browser/components/ServiceTierPicker/ServiceTierPicker.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | ||
| import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; | ||
| import { installDom } from "../../../../tests/ui/dom"; | ||
|
|
||
| import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; | ||
| import { getServiceTierKey } from "@/common/constants/storage"; | ||
| import { ServiceTierPicker } from "./ServiceTierPicker"; | ||
|
|
||
| const OPENAI_MODEL = "openai:gpt-5.5"; | ||
| const ANTHROPIC_MODEL = "anthropic:claude-haiku-4-5"; | ||
| const SCOPE = "ws-service-tier-test"; | ||
|
|
||
| let cleanupDom: (() => void) | null = null; | ||
|
|
||
| function renderPicker(modelString: string) { | ||
| return render( | ||
| <TooltipProvider> | ||
| <ServiceTierPicker modelString={modelString} scopeId={SCOPE} /> | ||
| </TooltipProvider> | ||
| ); | ||
| } | ||
|
|
||
| describe("ServiceTierPicker", () => { | ||
| beforeEach(() => { | ||
| cleanupDom = installDom(); | ||
| globalThis.window.localStorage.clear(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| cleanupDom?.(); | ||
| cleanupDom = null; | ||
| }); | ||
|
|
||
| test("renders nothing for models without service-tier support", () => { | ||
| const { queryByTestId } = renderPicker(ANTHROPIC_MODEL); | ||
| expect(queryByTestId("service-tier-trigger")).toBeNull(); | ||
| }); | ||
|
|
||
| test("shows the neutral (default) state for a supported model with no override", () => { | ||
| const { getByTestId } = renderPicker(OPENAI_MODEL); | ||
| const trigger = getByTestId("service-tier-trigger"); | ||
| expect(trigger.getAttribute("data-service-tier")).toBe("default"); | ||
| }); | ||
|
|
||
| test("opens a menu and applies the Fast override", async () => { | ||
| const { getByTestId, queryByTestId, getAllByTestId } = renderPicker(OPENAI_MODEL); | ||
|
|
||
| // Menu is closed initially. | ||
| expect(queryByTestId("service-tier-option")).toBeNull(); | ||
|
|
||
| fireEvent.click(getByTestId("service-tier-trigger")); | ||
|
|
||
| await waitFor(() => { | ||
| expect(getAllByTestId("service-tier-option").length).toBe(3); | ||
| }); | ||
|
|
||
| const fast = getAllByTestId("service-tier-option").find( | ||
| (el) => el.getAttribute("data-speed") === "fast" | ||
| ); | ||
| expect(fast).toBeTruthy(); | ||
| fireEvent.click(fast!); | ||
|
|
||
| await waitFor(() => { | ||
| expect(getByTestId("service-tier-trigger").getAttribute("data-service-tier")).toBe("fast"); | ||
| }); | ||
|
|
||
| // Override is persisted under the scoped key as the provider wire value. | ||
| expect(globalThis.window.localStorage.getItem(getServiceTierKey(SCOPE))).toBe( | ||
| JSON.stringify("priority") | ||
| ); | ||
| // Menu closes after selection. | ||
| expect(queryByTestId("service-tier-option")).toBeNull(); | ||
| }); | ||
|
|
||
| test("applies the Slow override", async () => { | ||
| const { getByTestId, getAllByTestId } = renderPicker(OPENAI_MODEL); | ||
| fireEvent.click(getByTestId("service-tier-trigger")); | ||
|
|
||
| await waitFor(() => expect(getAllByTestId("service-tier-option").length).toBe(3)); | ||
| const slow = getAllByTestId("service-tier-option").find( | ||
| (el) => el.getAttribute("data-speed") === "slow" | ||
| ); | ||
| fireEvent.click(slow!); | ||
|
|
||
| await waitFor(() => { | ||
| expect(getByTestId("service-tier-trigger").getAttribute("data-service-tier")).toBe("slow"); | ||
| }); | ||
| expect(globalThis.window.localStorage.getItem(getServiceTierKey(SCOPE))).toBe( | ||
| JSON.stringify("flex") | ||
| ); | ||
| }); | ||
|
|
||
| test("selecting Auto clears an existing override", async () => { | ||
| // Seed an existing Fast override. | ||
| globalThis.window.localStorage.setItem(getServiceTierKey(SCOPE), JSON.stringify("priority")); | ||
|
|
||
| const { getByTestId, getAllByTestId } = renderPicker(OPENAI_MODEL); | ||
| expect(getByTestId("service-tier-trigger").getAttribute("data-service-tier")).toBe("fast"); | ||
|
|
||
| fireEvent.click(getByTestId("service-tier-trigger")); | ||
| await waitFor(() => expect(getAllByTestId("service-tier-option").length).toBe(3)); | ||
| const auto = getAllByTestId("service-tier-option").find( | ||
| (el) => el.getAttribute("data-speed") === "default" | ||
| ); | ||
| fireEvent.click(auto!); | ||
|
|
||
| await waitFor(() => { | ||
| expect(getByTestId("service-tier-trigger").getAttribute("data-service-tier")).toBe("default"); | ||
| }); | ||
| // Auto clears the override entirely (key removed), so the provider/global default applies. | ||
| expect(globalThis.window.localStorage.getItem(getServiceTierKey(SCOPE))).toBeNull(); | ||
| }); | ||
| }); |
225 changes: 225 additions & 0 deletions
225
src/browser/components/ServiceTierPicker/ServiceTierPicker.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { Check, Zap } from "lucide-react"; | ||
|
|
||
| import { cn } from "@/common/lib/utils"; | ||
| import { type ServiceTier } from "@/common/config/schemas/providersConfig"; | ||
| import { | ||
| getServiceTierSpeed, | ||
| SERVICE_TIER_FAST, | ||
| SERVICE_TIER_SLOW, | ||
| supportsServiceTier, | ||
| type ServiceTierSpeed, | ||
| } from "@/common/utils/ai/serviceTier"; | ||
| import { useServiceTier } from "@/browser/hooks/useServiceTier"; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip"; | ||
| import { stopKeyboardPropagation } from "@/browser/utils/events"; | ||
|
|
||
| interface ServiceTierPickerProps { | ||
| /** Canonical model string used to gate visibility (only shown for supporting models). */ | ||
| modelString: string; | ||
| /** Workspace id (workspace view) or project scope id (creation view). */ | ||
| scopeId: string; | ||
| className?: string; | ||
| } | ||
|
|
||
| interface ServiceTierOption { | ||
| speed: ServiceTierSpeed; | ||
| /** null clears the override (falls back to the provider/global default). */ | ||
| tier: ServiceTier | null; | ||
| label: string; | ||
| description: string; | ||
| } | ||
|
|
||
| // "Fast"/"Slow"/"Auto" wording keeps the control provider-agnostic even though | ||
| // only OpenAI honors service_tier today. | ||
| const OPTIONS: readonly ServiceTierOption[] = [ | ||
| { speed: "default", tier: null, label: "Auto", description: "Provider default speed" }, | ||
| { | ||
| speed: "fast", | ||
| tier: SERVICE_TIER_FAST, | ||
| label: "Fast", | ||
| description: "Prioritize low latency (higher cost)", | ||
| }, | ||
| { | ||
| speed: "slow", | ||
| tier: SERVICE_TIER_SLOW, | ||
| label: "Slow", | ||
| description: "Prioritize lower cost (higher latency)", | ||
| }, | ||
| ]; | ||
|
|
||
| /** CSS variable for the active speed, or undefined for the neutral (grey) state. */ | ||
| function getSpeedColorVar(speed: ServiceTierSpeed): string | undefined { | ||
| if (speed === "fast") return "var(--color-service-tier-fast)"; | ||
| if (speed === "slow") return "var(--color-service-tier-slow)"; | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Lightning-bolt control for the chat-specific service-tier (speed) override. | ||
| * | ||
| * - Fast → bolt glows orange, Slow → bolt turns blue, Auto/default → neutral grey. | ||
| * - Clicking opens a small keyboard-navigable menu that sets the per-chat override. | ||
| * | ||
| * Rendered only for models that support service tiers (OpenAI/GPT today). Uses | ||
| * conditional rendering (not a Radix portal) so it stays testable under happy-dom. | ||
| */ | ||
| export const ServiceTierPicker: React.FC<ServiceTierPickerProps> = (props) => { | ||
| const [serviceTier, setServiceTier] = useServiceTier(props.scopeId); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [highlightedIndex, setHighlightedIndex] = useState(-1); | ||
|
|
||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const dropdownRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const currentSpeed = getServiceTierSpeed(serviceTier); | ||
|
|
||
| const closePicker = useCallback(() => { | ||
| setIsOpen(false); | ||
| setHighlightedIndex(-1); | ||
| }, []); | ||
|
|
||
| const openPicker = useCallback(() => { | ||
| setIsOpen(true); | ||
| const currentIndex = OPTIONS.findIndex((opt) => opt.speed === currentSpeed); | ||
| setHighlightedIndex(currentIndex >= 0 ? currentIndex : 0); | ||
| requestAnimationFrame(() => dropdownRef.current?.focus()); | ||
| }, [currentSpeed]); | ||
|
|
||
| const handleSelect = useCallback( | ||
| (option: ServiceTierOption) => { | ||
| setServiceTier(option.tier); | ||
| closePicker(); | ||
| }, | ||
| [closePicker, setServiceTier] | ||
| ); | ||
|
|
||
| // Close when clicking outside the control. | ||
| useEffect(() => { | ||
| if (!isOpen) { | ||
| return; | ||
| } | ||
| const handleClickOutside = (e: MouseEvent) => { | ||
| if (containerRef.current?.contains(e.target as Node)) { | ||
| return; | ||
| } | ||
| closePicker(); | ||
| }; | ||
| document.addEventListener("mousedown", handleClickOutside); | ||
| return () => document.removeEventListener("mousedown", handleClickOutside); | ||
| }, [closePicker, isOpen]); | ||
|
|
||
| const handleDropdownKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { | ||
| if (e.key === "Escape") { | ||
| e.preventDefault(); | ||
| stopKeyboardPropagation(e); | ||
| closePicker(); | ||
| return; | ||
| } | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| const option = OPTIONS[highlightedIndex >= 0 ? highlightedIndex : 0]; | ||
| if (option) { | ||
| handleSelect(option); | ||
| } | ||
| return; | ||
| } | ||
| if (e.key === "ArrowDown") { | ||
| e.preventDefault(); | ||
| setHighlightedIndex((prev) => Math.min(prev + 1, OPTIONS.length - 1)); | ||
| return; | ||
| } | ||
| if (e.key === "ArrowUp") { | ||
| e.preventDefault(); | ||
| setHighlightedIndex((prev) => Math.max(prev - 1, 0)); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // Only models that honor service tiers expose this affordance. | ||
| if (!supportsServiceTier(props.modelString)) { | ||
| return null; | ||
| } | ||
|
|
||
| const activeColor = getSpeedColorVar(currentSpeed); | ||
| const activeLabel = OPTIONS.find((opt) => opt.speed === currentSpeed)?.label ?? "Auto"; | ||
|
|
||
| return ( | ||
| <div ref={containerRef} className={cn("relative flex items-center", props.className)}> | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <button | ||
| type="button" | ||
| onClick={() => (isOpen ? closePicker() : openPicker())} | ||
| data-testid="service-tier-trigger" | ||
| data-service-tier={currentSpeed} | ||
| aria-haspopup="menu" | ||
| aria-expanded={isOpen} | ||
| aria-label={`Service tier: ${activeLabel}. Click to change.`} | ||
| className={cn( | ||
| "flex h-4 w-4 items-center justify-center rounded-sm transition-colors", | ||
| activeColor ? "" : "text-muted hover:text-foreground hover:bg-hover" | ||
| )} | ||
| style={ | ||
| activeColor | ||
| ? { | ||
| color: activeColor, | ||
| // Orange "glow" for Fast; a softer halo for Slow. | ||
| filter: `drop-shadow(0 0 ${currentSpeed === "fast" ? "5px" : "3px"} ${activeColor})`, | ||
| } | ||
| : undefined | ||
| } | ||
| > | ||
| <Zap className="h-3 w-3" /> | ||
| </button> | ||
| </TooltipTrigger> | ||
| <TooltipContent align="center"> | ||
| Service tier: <span className="font-medium">{activeLabel}</span>. Sets request speed for | ||
| this chat. Saved per workspace. | ||
| </TooltipContent> | ||
| </Tooltip> | ||
|
|
||
| {isOpen && ( | ||
| <div | ||
| ref={dropdownRef} | ||
| tabIndex={-1} | ||
| role="menu" | ||
| onKeyDown={handleDropdownKeyDown} | ||
| className="bg-separator border-border-light absolute bottom-full left-0 z-[1020] mb-1 min-w-48 overflow-hidden rounded border shadow-[0_4px_12px_rgba(0,0,0,0.3)] outline-none" | ||
| > | ||
| <div className="py-1"> | ||
| {OPTIONS.map((option, index) => { | ||
| const isHighlighted = index === highlightedIndex; | ||
| const isSelected = option.speed === currentSpeed; | ||
| const color = getSpeedColorVar(option.speed); | ||
| return ( | ||
| <div | ||
| key={option.speed} | ||
| role="menuitemradio" | ||
| aria-checked={isSelected} | ||
| tabIndex={-1} | ||
| data-testid="service-tier-option" | ||
| data-speed={option.speed} | ||
| className={cn( | ||
| "flex cursor-pointer items-center gap-2.5 px-2.5 py-1.5 transition-colors duration-100", | ||
| isHighlighted ? "bg-hover text-foreground" : "bg-transparent hover:bg-hover", | ||
| isSelected ? "text-foreground" : "text-light hover:text-foreground" | ||
| )} | ||
| onMouseEnter={() => setHighlightedIndex(index)} | ||
| onClick={() => handleSelect(option)} | ||
| > | ||
| <Zap className="h-3.5 w-3.5 shrink-0" style={color ? { color } : undefined} /> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="text-[11px] font-medium">{option.label}</div> | ||
| <div className="text-muted-light text-[10px]">{option.description}</div> | ||
| </div> | ||
| {isSelected && <Check className="text-accent h-3.5 w-3.5 shrink-0" />} | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.