diff --git a/frontend/src/components/ui/inspector-shell.test.tsx b/frontend/src/components/ui/inspector-shell.test.tsx index 8d3dba78..075d67b7 100644 --- a/frontend/src/components/ui/inspector-shell.test.tsx +++ b/frontend/src/components/ui/inspector-shell.test.tsx @@ -323,6 +323,12 @@ async function waitForAnimationFrame() { await new Promise((resolve) => requestAnimationFrame(() => resolve())); } +// Lets the focus a freshly mounted layer defers to its next frames run before the test moves on. +async function settleFocus() { + await waitForAnimationFrame(); + await waitForAnimationFrame(); +} + function setViewportWidth(width: number) { Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); window.dispatchEvent(new Event("resize")); @@ -703,7 +709,10 @@ describe("InspectorShell", () => { await user.click(screen.getByRole("button", { name: "Deploy" })); setViewportWidth(720); - await waitForAnimationFrame(); + await waitFor(() => + expect(screen.getByTestId("inspector-content")).not.toHaveAttribute("data-inspector-panel"), + ); + await settleFocus(); const inspector = screen.getByTestId("inspector-content"); expect(inspector).toHaveStyle({ zIndex: "calc(var(--layer-modal) - 1)" }); @@ -717,7 +726,7 @@ describe("InspectorShell", () => { expect(screen.queryByRole("dialog", { name: "Deploy resource" })).not.toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "Responsive inspector" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Deploy" })).toHaveFocus(); + await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toHaveFocus()); }); it("keeps an active child dialog isolated when the inspector crosses into wide mode", async () => { @@ -729,7 +738,10 @@ describe("InspectorShell", () => { await user.click(screen.getByRole("button", { name: "Deploy" })); setViewportWidth(721); - await waitForAnimationFrame(); + await waitFor(() => + expect(screen.getByTestId("inspector-content")).toHaveAttribute("data-inspector-panel"), + ); + await settleFocus(); const inspector = screen.getByTestId("inspector-content"); expect(inspector).toHaveAttribute("aria-hidden", "true"); @@ -742,7 +754,7 @@ describe("InspectorShell", () => { expect(screen.queryByRole("dialog", { name: "Deploy resource" })).not.toBeInTheDocument(); expect(screen.getByRole("complementary", { name: "Responsive inspector" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Deploy" })).toHaveFocus(); + await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toHaveFocus()); }); it("gives an active feature modal precedence over inspector dismissal", async () => { diff --git a/frontend/src/features/models/deploy/model-deploy-context-step.tsx b/frontend/src/features/models/deploy/model-deploy-context-step.tsx index 87b0113d..65eb31a0 100644 --- a/frontend/src/features/models/deploy/model-deploy-context-step.tsx +++ b/frontend/src/features/models/deploy/model-deploy-context-step.tsx @@ -1,13 +1,15 @@ import type { ReactNode, RefObject } from "react"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DeploymentForm, DeploymentStepHeading, } from "@/features/deployments/deployment-form-layout"; import { ContextSizeField } from "@/features/models/runtime/context-size-field"; -import type { ContextSizeRange } from "@/features/models/runtime/context-size-input"; +import type { + ContextSizeLargestFit, + ContextSizeRange, +} from "@/features/models/runtime/context-size-input"; import { engineParameterHelp, maximumContext, @@ -28,6 +30,8 @@ interface ModelDeployContextStepProps { /** The context per request the backend fills while the input is empty, from the latest estimate. */ filledContextPerRequest?: number; inputs: ReadonlyMap; + /** The largest total context that fits the selected target, offered as the total's last chip. */ + largestFit?: ContextSizeLargestFit; model: Model; onParameterBlur: (parameter: EngineParameterDefinition) => void; /** Receives each change, and whether it is settled, as a slider or preset is, or typed. */ @@ -37,11 +41,6 @@ interface ModelDeployContextStepProps { settled?: boolean, ) => void; profile?: ModelProfile; - /** - * Where the total context comes from: its label, and a reset back to the default once the - * operator chose it. Absent while the engine default applies. - */ - totalSource?: { label: string; onReset?: () => void }; } /** @@ -57,11 +56,11 @@ export function ModelDeployContextStep({ fieldErrors, filledContextPerRequest, inputs, + largestFit, model, onParameterBlur, onParameterChange, profile, - totalSource, }: ModelDeployContextStepProps) { const configuration = model.configurations.find( (candidate) => candidate.configurationId === profile?.configurationId, @@ -75,7 +74,12 @@ export function ModelDeployContextStep({ const sliderRange = contextSliderRange([totalContext, contextPerRequest]); const contextField = ( parameter: EngineParameterDefinition, - field: { emptyValue?: number; help?: ReactNode; hint?: string; source?: ReactNode } = {}, + field: { + emptyValue?: number; + help?: ReactNode; + hint?: string; + largestFit?: ContextSizeLargestFit; + } = {}, ) => ( - {totalSource.label} - {totalSource.onReset ? ( - - ) : null} - - ) : undefined, + largestFit, }) : null} {contextPerRequest diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.tsx index c28c016f..493a77ef 100644 --- a/frontend/src/features/models/deploy/model-deploy-dialog.tsx +++ b/frontend/src/features/models/deploy/model-deploy-dialog.tsx @@ -222,11 +222,11 @@ export function ModelDeployDialog(props: ModelDeployDialogProps) { fieldErrors={form.contextFieldErrors} filledContextPerRequest={memory.estimate.filledContextPerRequest} inputs={form.engineParameterInputs} + largestFit={form.largestFit} model={model} onParameterBlur={form.blurContext} onParameterChange={form.changeContext} profile={form.profile} - totalSource={form.totalSource} /> ) : null} {step === "Runtime" ? ( diff --git a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx index eb5c69ff..15ef59ac 100644 --- a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx +++ b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx @@ -884,47 +884,52 @@ describe("ModelDeployDialog shared context", () => { it.each([ { case: "a fit above parallel requests × the maximum", - hint: "Up to 1953k on node / NVIDIA GeForce RTX 5090", + chip: "1.9M", + hint: "Up to 1.9M on node / NVIDIA GeForce RTX 5090", source: StartingTotalContextSource.LARGEST_FIT, starting: 1_999_872, total: 1999872n, }, { case: "no fit", + chip: undefined, hint: undefined, source: StartingTotalContextSource.PROFILE_DEFAULT, starting: 32768, total: 32768n, }, - ])("starts total context at $total with $case", async ({ hint, source, starting, total }) => { - const user = setupUser(); - const onSubmit = vi.fn["onSubmit"]>(); - renderDialog({ - initialProfileId: "q4-xl", - listProfileCompatibility: scenarioLoader({}, [ - targetCompatibility(poolPlacement, { - "q4-xl": { - startingTotalContextSource: source, - startingTotalContextTokens: starting, - }, - }), - ]), - model: longContextModel, - onSubmit, - }); + ])( + "starts total context at $total with $case", + async ({ chip, hint, source, starting, total }) => { + const user = setupUser(); + const onSubmit = vi.fn["onSubmit"]>(); + renderDialog({ + initialProfileId: "q4-xl", + listProfileCompatibility: scenarioLoader({}, [ + targetCompatibility(poolPlacement, { + "q4-xl": { + startingTotalContextSource: source, + startingTotalContextTokens: starting, + }, + }), + ]), + model: longContextModel, + onSubmit, + }); - await screen.findByRole("button", { name: "Runs on filter: this site" }); - await user.click(screen.getByRole("button", { name: "Next" })); - // Only a starting value from the largest fit is hinted and labeled as one. - expect(screen.queryByText(/^Up to .* on /)?.textContent).toBe(hint); - expect(screen.queryByText("Largest fit") !== null).toBe(hint !== undefined); - await user.click(screen.getByRole("button", { name: "Next" })); - await user.click(screen.getByRole("button", { name: "Deploy" })); + await screen.findByRole("button", { name: "Runs on filter: this site" }); + await user.click(screen.getByRole("button", { name: "Next" })); + // Only a starting value from the largest fit is hinted and offered as the last chip. + expect(screen.queryByText(/^Up to .* on /)?.textContent).toBe(hint); + expect(screen.queryByRole("button", { name: /, largest fit$/ })?.textContent).toBe(chip); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(screen.getByRole("button", { name: "Deploy" })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); - const submitted = pairs(onSubmit.mock.calls[0][0].configuration.engineParameterValues.values); - expect(submitted.find(([id]) => id === "total-context")).toEqual(["total-context", total]); - }); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + const submitted = pairs(onSubmit.mock.calls[0][0].configuration.engineParameterValues.values); + expect(submitted.find(([id]) => id === "total-context")).toEqual(["total-context", total]); + }, + ); it("starts total context at the engine default when the read fails", async () => { const user = setupUser(); @@ -972,7 +977,7 @@ describe("ModelDeployDialog shared context", () => { await user.type(parallel, "1"); await user.click(screen.getByRole("button", { name: "Back" })); expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336"); - expect(screen.getByText("Largest fit")).toBeVisible(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true"); await user.click(screen.getByRole("button", { name: "Next" })); await user.click(screen.getByRole("button", { name: "Next" })); @@ -985,7 +990,7 @@ describe("ModelDeployDialog shared context", () => { ]); }); - it("labels where the total context comes from, and returns an edited one to the largest fit", async () => { + it("offers the largest fit as the last chip, and returns an edited total to it", async () => { const user = setupUser(); renderDialog({ initialProfileId: "q4-xl", @@ -1000,29 +1005,71 @@ describe("ModelDeployDialog shared context", () => { await screen.findByRole("button", { name: "Runs on filter: this site" }); await user.click(screen.getByRole("button", { name: "Next" })); const total = screen.getByRole("textbox", { name: "Total context" }); - expect(await screen.findByText("Largest fit")).toBeVisible(); - expect(screen.queryByRole("button", { name: "Use largest fit" })).not.toBeInTheDocument(); + const presets = within(screen.getByRole("group", { name: "Total context presets" })); + // The sizes stop below the fit, and the fit comes last. + await waitFor(() => expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true")); + expect(presets.getAllByRole("button").map((chip) => chip.textContent)).toEqual([ + "8k", + "32k", + "64k", + "128k", + "256k", + "889k", + ]); - await user.click( - within(screen.getByRole("group", { name: "Total context presets" })).getByRole("button", { - name: "64k", - }), - ); + await user.click(presets.getByRole("button", { name: "64k" })); expect(total).toHaveValue("65536"); - expect(screen.getByText("Custom")).toBeVisible(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "false"); await user.click(screen.getByRole("button", { name: "Back" })); expect(screen.getByRole("button", { name: "Runs on filter: this site, 64k" })).toBeVisible(); await user.click(screen.getByRole("button", { name: "Next" })); - await user.click(screen.getByRole("button", { name: "Use largest fit" })); + await user.click(largestFitChip("889k")); expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336"); - expect(screen.getByText("Largest fit")).toBeVisible(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText("Up to 889k on node / NVIDIA GeForce RTX 5090")).toBeVisible(); await user.click(screen.getByRole("button", { name: "Back" })); expect(screen.getByRole("button", { name: "Runs on filter: this site" })).toBeVisible(); }); - it("labels the total context custom only when its value leaves the largest fit", async () => { + it.each([ + { case: "a fit equal to a size", chips: ["8k", "32k", "64k", "128k", "256k"], fit: 262144 }, + { case: "a fit below most sizes", chips: ["8k", "12k"], fit: 12288 }, + { case: "a fit below every size", chips: ["4k"], fit: 4096 }, + { + case: "a fit above the largest size", + chips: ["8k", "32k", "64k", "128k", "256k", "1M", "1.9M"], + fit: 1_999_872, + }, + ])("offers sizes below the largest fit, then the fit, with $case", async ({ chips, fit }) => { + const user = setupUser(); + renderDialog({ + initialProfileId: "q4-xl", + listProfileCompatibility: scenarioLoader({}, [ + targetCompatibility(poolPlacement, { + "q4-xl": { startingTotalContextTokens: fit }, + }), + ]), + model: longContextModel, + }); + + await screen.findByRole("button", { name: "Runs on filter: this site" }); + await user.click(screen.getByRole("button", { name: "Next" })); + await screen.findByRole("button", { name: /, largest fit$/ }); + + expect( + within(screen.getByRole("group", { name: "Total context presets" })) + .getAllByRole("button") + .map((chip) => chip.textContent), + ).toEqual(chips); + expect(screen.getByRole("button", { name: /, largest fit$/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue(String(fit)); + }); + + it("presses the largest fit chip only while the total's value is the largest fit", async () => { const user = setupUser(); renderDialog({ initialProfileId: "q4-xl", @@ -1038,28 +1085,29 @@ describe("ModelDeployDialog shared context", () => { await user.click(screen.getByRole("button", { name: "Next" })); const total = screen.getByRole("textbox", { name: "Total context" }); const perRequest = screen.getByRole("textbox", { name: "Context per request" }); - expect(await screen.findByText("Largest fit")).toBeVisible(); + await waitFor(() => expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true")); - // Per request only: the total keeps the largest fit's value and badge. + // Per request only: the total keeps the largest fit's value and the chip stays pressed. await user.clear(perRequest); await user.type(perRequest, "32768"); await user.tab(); expect(total).toHaveValue("910336"); - expect(screen.getByText("Largest fit")).toBeVisible(); - expect(screen.queryByText("Custom")).not.toBeInTheDocument(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true"); + + // Clicking the pressed chip changes nothing, the chosen per request value included. + await user.click(largestFitChip("889k")); + expect(total).toHaveValue("910336"); + expect(perRequest).toHaveValue("32768"); await user.clear(total); await user.type(total, "65536"); await user.tab(); - expect(screen.getByText("Custom")).toBeVisible(); - expect(screen.getByRole("button", { name: "Use largest fit" })).toBeVisible(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "false"); await user.clear(total); await user.type(total, "910336"); await user.tab(); - expect(screen.getByText("Largest fit")).toBeVisible(); - expect(screen.queryByText("Custom")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Use largest fit" })).not.toBeInTheDocument(); + expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true"); }); it('drops a stale total context error when "Sized for" chooses the context', async () => { @@ -1119,7 +1167,7 @@ describe("ModelDeployDialog shared context", () => { expect(await screen.findByText(parallelError)).toBeVisible(); await user.click(screen.getByRole("button", { name: "Back" })); - await user.click(screen.getByRole("button", { name: "Use largest fit" })); + await user.click(largestFitChip("889k")); expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336"); await user.click(screen.getByRole("button", { name: "Next" })); expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("0"); @@ -1430,7 +1478,7 @@ describe("ModelDeployDialog scenario polling", () => { const expectFirstFit = () => { expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("126976"); expect(screen.getByText(/^Up to 124k on /)).toBeVisible(); - expect(screen.getByText("Largest fit")).toBeVisible(); + expect(largestFitChip("124k")).toHaveAttribute("aria-pressed", "true"); }; expectFirstFit(); @@ -1469,7 +1517,7 @@ describe("ModelDeployDialog scenario polling", () => { expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("126976"); }); - it("applies the live starting total context on Use largest fit, and keeps it from then on", async () => { + it("applies the live starting total context on choosing the largest fit chip, and keeps it from then on", async () => { setTiming(productionTiming); vi.useFakeTimers(); // What every read reports from now on, as free memory changes. @@ -1503,11 +1551,13 @@ describe("ModelDeployDialog scenario polling", () => { ); await advance(); expect(total()).toHaveValue("65536"); - fireEvent.click(screen.getByRole("button", { name: "Use largest fit" })); + // Choosing a size reads the new scenario, whose fit the chip then offers. + expect(largestFitChip("96k")).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(largestFitChip("96k")); await advance(); expect(total()).toHaveValue("98304"); expect(screen.getByText(/^Up to 96k on /)).toBeVisible(); - expect(screen.getByText("Largest fit")).toBeVisible(); + expect(largestFitChip("96k")).toHaveAttribute("aria-pressed", "true"); // The corrected value is frozen in turn. live = 65536; @@ -1714,6 +1764,11 @@ async function advance(ms = 0) { } // Picks a "Sized for" choice in the Runs on chip's menu, then closes the menu. +/** The total context's last chip, named by the largest fit it stands for. */ +function largestFitChip(size: string) { + return screen.getByRole("button", { name: `${size}, largest fit` }); +} + async function chooseSizedFor(user: UserEvent, chip: string, choice: "Custom" | "Defaults") { await user.click(await screen.findByRole("button", { name: chip })); await user.click( diff --git a/frontend/src/features/models/deploy/use-model-deploy-form.ts b/frontend/src/features/models/deploy/use-model-deploy-form.ts index f86a337d..2881bf1d 100644 --- a/frontend/src/features/models/deploy/use-model-deploy-form.ts +++ b/frontend/src/features/models/deploy/use-model-deploy-form.ts @@ -398,26 +398,23 @@ export function useModelDeployForm({ context, fitStart, ); - // Where the total context comes from: the operator's choice when it differs from the largest fit, - // which the largest fit can replace again, else the largest fit. A chosen context per request - // pins the total too, so the value, not the pin, decides. Returning to the largest fit forgets - // the default's frozen value, so the next read of the defaults freezes the live one and a value - // frozen from a stale read is corrected. - const customTotal = Boolean(context.inputs && Number(context.inputs.total) !== fitStart); - const totalSource = customTotal - ? { - label: "Custom", - onReset: - readFitStart === undefined - ? undefined - : () => { - forgetDefaultStart(); - resetContext(); - }, - } - : fitStart === undefined + // The largest fit offered as the total context's last chip. While the total follows it or the + // operator's total equals it, the chip reads as chosen. Choosing it otherwise forgets the default's + // frozen value, so the next read of the defaults freezes the live one and a value frozen from a + // stale read is corrected; without a read to freeze, it sets the value like a size chip. + const largestFit = + fitStart === undefined ? undefined - : { label: "Largest fit" }; + : { + onSelect: + readFitStart === undefined + ? undefined + : () => { + forgetDefaultStart(); + resetContext(); + }, + value: fitStart, + }; const selectedProfileRow = profileRows.find((row) => row.profileId === profileId); const selectedAvailability = selectedProfileRow && profileAvailability?.(selectedProfileRow); @@ -918,6 +915,7 @@ export function useModelDeployForm({ ineligibleTarget, kvCachePrecision, kvCachePrecisionOption, + largestFit, launchValuesLoadingVisible, memory, name, @@ -960,7 +958,6 @@ export function useModelDeployForm({ targetsLoadingPresentation, targetsLoadingVisible, toggleAdvanced, - totalSource, updateCustomArguments, updateShownProfiles, validateRoute, diff --git a/frontend/src/features/models/runtime/context-size-field.tsx b/frontend/src/features/models/runtime/context-size-field.tsx index bae3acf5..9090de8d 100644 --- a/frontend/src/features/models/runtime/context-size-field.tsx +++ b/frontend/src/features/models/runtime/context-size-field.tsx @@ -3,7 +3,11 @@ import type { ReactNode, RefObject } from "react"; import { FieldError } from "@/components/ui/field-error"; import type { EngineParameterDefinition } from "@/gen/dotcore/catalog/v1/engine_release_pb"; -import { ContextSizeInput, type ContextSizeRange } from "./context-size-input"; +import { + ContextSizeInput, + type ContextSizeLargestFit, + type ContextSizeRange, +} from "./context-size-input"; interface ContextSizeFieldProps { disabled: boolean; @@ -16,6 +20,8 @@ interface ContextSizeFieldProps { hint?: string; input: string; inputRefs: RefObject>; + /** The largest size that fits the selected target, offered as the input's last chip. */ + largestFit?: ContextSizeLargestFit; onBlur: (parameter: EngineParameterDefinition) => void; /** Receives each change, and whether it is settled rather than typing in progress. */ onChange: (parameter: EngineParameterDefinition, input: string, settled: boolean) => void; @@ -23,8 +29,6 @@ interface ContextSizeFieldProps { parameter: EngineParameterDefinition; /** The range the slider is drawn over, shared with other context fields so they line up. */ sliderRange?: ContextSizeRange; - /** Where the value comes from, shown after the name. */ - source?: ReactNode; } /** @@ -40,11 +44,11 @@ export function ContextSizeField({ hint, input, inputRefs, + largestFit, onBlur, onChange, parameter, sliderRange, - source, }: ContextSizeFieldProps) { if (parameter.parameterType.case !== "unsignedInteger") return null; @@ -60,10 +64,7 @@ export function ContextSizeField({ return (
- - - {source} - + {hint ? ( {hint} @@ -79,6 +80,7 @@ export function ContextSizeField({ inputRefs.current.set(parameter.parameterId, control); }} invalid={Boolean(error)} + largestFit={largestFit} label={parameter.displayName} maximum={Number(maximum)} minimum={Number(minimum)} diff --git a/frontend/src/features/models/runtime/context-size-input.tsx b/frontend/src/features/models/runtime/context-size-input.tsx index 9ccee070..904e3f6b 100644 --- a/frontend/src/features/models/runtime/context-size-input.tsx +++ b/frontend/src/features/models/runtime/context-size-input.tsx @@ -19,6 +19,13 @@ interface ContextSizeInputProps { id: string; inputRef?: Ref; invalid?: boolean; + /** + * The largest size that fits where the model runs, offered as the last chip. The size chips + * stop below it, and a chip equal to it gives way. The chip is pressed while the value equals it, + * and then does nothing when clicked. Its click runs "onSelect" when given, such as to return + * the owner to following the fit, else sets the value like a size chip. + */ + largestFit?: ContextSizeLargestFit; /** The accessible name of the slider and, with "presets", of the preset group. */ label: string; maximum: number; @@ -41,6 +48,12 @@ interface ContextSizeInputProps { value: string; } +/** The largest size that fits, and what choosing it does beyond setting the value, if anything. */ +export interface ContextSizeLargestFit { + onSelect?: () => void; + value: number; +} + /** A range of context sizes, in tokens. */ export interface ContextSizeRange { maximum: number; @@ -48,9 +61,9 @@ export interface ContextSizeRange { } /** - * A context size control: a log-scale slider, an exact number input, and preset chips, all bounded - * by the given range. The number input takes any text, so its owner validates it; the slider and - * presets only write values within the range. + * A context size control: a log-scale slider, an exact number input, and preset chips with an + * optional largest fit chip last, all bounded by the given range. The number input takes any text, + * so its owner validates it; the slider and size chips only write values within the range. */ export function ContextSizeInput({ describedBy, @@ -59,6 +72,7 @@ export function ContextSizeInput({ id, inputRef, invalid = false, + largestFit, label, maximum, minimum, @@ -76,7 +90,12 @@ export function ContextSizeInput({ const clamp = (size: number) => Math.min(Math.max(size, minimum), maximum); const value = /^\d+$/.test(input) ? Number(input) : undefined; const shownValue = value ?? (input === "" ? emptyValue : undefined); - const presets = contextSizePresets.filter((preset) => preset >= minimum && preset <= maximum); + const presets = contextSizePresets.filter( + (preset) => + preset >= minimum && + preset <= maximum && + (largestFit === undefined || preset < largestFit.value), + ); const reachableEnd = sliderPosition(scale, maximum) / SLIDER_STEPS; const reachableStart = sliderPosition(scale, minimum) / SLIDER_STEPS; @@ -124,7 +143,7 @@ export function ContextSizeInput({ {unit}
- {presets.length ? ( + {presets.length || largestFit ? (
{presets.map((preset) => ( + ) : null}
) : null} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 7cbe1bfe..17b74a25 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -99,6 +99,8 @@ describe("formatCompactCount", () => { it("switches to millions", () => { expect(formatCompactCount(1_500_000)).toBe("1.5M"); expect(formatCompactCount(1024 ** 2)).toBe("1M"); + expect(formatCompactCount(3405 * 1024)).toBe("3.3M"); + expect(formatCompactCount(1_999_872)).toBe("1.9M"); expect(formatCompactCount(999_600)).toBe("1M"); }); }); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 1eab86a1..4cf7e7e6 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -74,15 +74,15 @@ export function formatQuantity(value: bigint | number, unit: string): string { /** * Formats a count compactly, such as "250k" or "1.5M". A count that is a multiple of 1024 counts - * binary thousands (131072 is "128k"); any other counts decimal ones (250000 is "250k"). Counts - * under 1000 are shown as is. + * binary thousands and millions (131072 is "128k", 3486720 is "3.3M"); any other counts decimal + * ones (250000 is "250k"). Counts under 1000 are shown as is. */ export function formatCompactCount(count: number): string { if (count < 1000) return String(count); if (count % 1024 === 0) { const thousands = count / 1024; - return thousands >= 1024 && thousands % 1024 === 0 ? `${thousands / 1024}M` : `${thousands}k`; + return thousands >= 1024 ? `${compactMillions.format(thousands / 1024)}M` : `${thousands}k`; } const thousands = Math.round(count / 1000);