feat(web): settings form polish — dialog layout, homepage button, no default rows, unified dropdown text (#39)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+363
-183
No files matched your search
@@ -43,6 +43,11 @@ export interface OptionMenuChoice<T extends string> {
|
|||||||
|
|
||||||
const PANEL_WIDTH = 288; // w-72
|
const PANEL_WIDTH = 288; // w-72
|
||||||
|
|
||||||
|
/** Row-title text follows the control's size tier, so the dropdown reads exactly like an Input of the same tier (review: dropdown text = input text); descriptions stay text-xs secondary. */
|
||||||
|
const rowTextClass: Record<ControlSize, string> = { base: "text-base", sm: "text-xs" };
|
||||||
|
/** Descriptions keep one step below the row title at every tier, so the title/description hierarchy survives the sm tier's text-xs titles. */
|
||||||
|
const rowDescClass: Record<ControlSize, string> = { base: "text-xs", sm: "text-[11px]" };
|
||||||
|
|
||||||
export function OptionMenu<T extends string>({
|
export function OptionMenu<T extends string>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -143,7 +148,7 @@ export function OptionMenu<T extends string>({
|
|||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`${rowTextClass[size]} ${
|
||||||
opt.value === value
|
opt.value === value
|
||||||
? "font-medium text-gray-900 dark:text-gray-100"
|
? "font-medium text-gray-900 dark:text-gray-100"
|
||||||
: "text-gray-700 dark:text-gray-300"
|
: "text-gray-700 dark:text-gray-300"
|
||||||
@@ -170,7 +175,9 @@ export function OptionMenu<T extends string>({
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-xs text-gray-500 dark:text-gray-400">
|
<span
|
||||||
|
className={`mt-0.5 block ${rowDescClass[size]} text-gray-500 dark:text-gray-400`}
|
||||||
|
>
|
||||||
{opt.description}
|
{opt.description}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const CONTROL_CLASS =
|
|||||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
|
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-gray-600 " +
|
||||||
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
|
"dark:focus:border-gray-400 dark:focus:ring-gray-500/30";
|
||||||
|
|
||||||
|
/** Menu-row text follows the control's size tier, so the dropdown reads exactly like an Input of the same tier (review: dropdown text = input text). */
|
||||||
|
const menuTextClass: Record<ControlSize, string> = { base: "text-base", sm: "text-xs" };
|
||||||
|
|
||||||
export function Select({
|
export function Select({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
@@ -169,7 +172,7 @@ export function Select({
|
|||||||
aria-selected={o.value === current}
|
aria-selected={o.value === current}
|
||||||
disabled={o.disabled}
|
disabled={o.disabled}
|
||||||
onClick={() => pick(o.value)}
|
onClick={() => pick(o.value)}
|
||||||
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors duration-150 disabled:opacity-50 ${
|
className={`flex w-full items-center px-3 py-1.5 text-left ${menuTextClass[size]} transition-colors duration-150 disabled:opacity-50 ${
|
||||||
o.value === current
|
o.value === current
|
||||||
? "bg-gray-100 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
? "bg-gray-100 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* iOS-style toggle switch: a `button` with `role="switch"` + `aria-checked` (a native
|
||||||
|
* button is keyboard-operable out of the box — Space/Enter activate it — and is labelable
|
||||||
|
* content, so clicking an enclosing `<label>`'s text toggles it too). Sliding knob with a
|
||||||
|
* color/transform transition; the on-state follows the theme accent variable (same source
|
||||||
|
* as Button primary), the off-state is gray; dark-mode aware; disabled dims and blocks.
|
||||||
|
* Sized for compact (sm) form rows, matching the dialogs' controls.
|
||||||
|
*/
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface SwitchProps extends Omit<
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
"onChange" | "type" | "role"
|
||||||
|
> {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Switch({ checked, onChange, disabled, className, ...rest }: SwitchProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={
|
||||||
|
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full " +
|
||||||
|
"transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400/30 " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-60 " +
|
||||||
|
(checked ? "bg-[var(--accent-bg)]" : "bg-gray-300 dark:bg-gray-600") +
|
||||||
|
` ${className ?? ""}`
|
||||||
|
}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`inline-block size-4 transform rounded-full bg-white shadow transition-transform duration-200 ${
|
||||||
|
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,16 +35,69 @@ import { thinkingLevelOptionsFor } from "../chat/thinking-level";
|
|||||||
|
|
||||||
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
||||||
|
|
||||||
/** "" represents not overridden (falls back to the current config), corresponding to the defaultValue placeholder row. */
|
/**
|
||||||
function withDefaultOption(
|
* Dropdown rows from a dictionary's [value, description] pairs (exported for unit tests).
|
||||||
|
* The "" (not-overridden / inherit) row is filtered out per review — the menus offer only
|
||||||
|
* concrete values in dictionary order, the user picks explicitly. An unset stored value
|
||||||
|
* simply matches no row, so the OptionMenu trigger falls back to its placeholder
|
||||||
|
* ((缺省)/(default), the same convention as the tools-table permission menu); nothing is
|
||||||
|
* ever written silently, and the reset link next to each menu rewinds a local pick back to "".
|
||||||
|
*/
|
||||||
|
export function optionRows(
|
||||||
entries: ReadonlyArray<readonly [string, string]>,
|
entries: ReadonlyArray<readonly [string, string]>,
|
||||||
): ReadonlyArray<OptionMenuChoice<string>> {
|
): ReadonlyArray<OptionMenuChoice<string>> {
|
||||||
return entries.map(([value, description]) => ({
|
return entries
|
||||||
value,
|
.filter(([value]) => value !== "")
|
||||||
triggerLabel: value || S.agent.defaultValue,
|
.map(([value, description]) => ({
|
||||||
label: value || S.agent.defaultValue,
|
value,
|
||||||
description,
|
triggerLabel: value,
|
||||||
}));
|
label: value,
|
||||||
|
description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OptionMenu whose pick can be explicitly rewound: the runtime menus no longer offer an
|
||||||
|
* inherit row, so without this an accidental pick could not be backed out before saving.
|
||||||
|
* The reset link (top-right of the label line, visible only while a value is picked) sets
|
||||||
|
* the LOCAL edit state back to "" — the save guard skips empty values, nothing is written.
|
||||||
|
* The unset trigger shows the (default) placeholder, matching the tools-table permission menu.
|
||||||
|
*/
|
||||||
|
function ResettableOptionMenu({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: ReadonlyArray<OptionMenuChoice<string>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<OptionMenu
|
||||||
|
label={label}
|
||||||
|
fullWidth
|
||||||
|
size="sm"
|
||||||
|
placeholder={S.agent.defaultValue}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
{value !== "" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={S.agent.resetToDefault}
|
||||||
|
aria-label={S.agent.resetToDefault}
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute right-0 top-0 text-xs text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{S.agent.resetToDefault}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
||||||
@@ -501,17 +554,18 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
|||||||
};
|
};
|
||||||
|
|
||||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||||
// "none" is no longer offered (many models cannot disable thinking) but stays a valid stored
|
// Thinking level composes both review rounds: the "" inherit row is filtered (the user picks
|
||||||
// value: assembly lives in thinkingLevelOptionsFor, gated on the **persisted** config — a
|
// explicitly; unset shows the (default) placeholder and the reset link rewinds to it), "none"
|
||||||
// misclick onto another tier keeps the legacy row until the change is actually saved, so the
|
// is no longer offered (many models cannot disable thinking) but stays a valid stored value —
|
||||||
// stored value stays reachable (see thinking-level.ts).
|
// when the **persisted** config carries it, a display-only legacy row is appended, so a
|
||||||
|
// misclick onto another tier keeps it reachable until the change is actually saved
|
||||||
|
// (see thinking-level.ts).
|
||||||
const thinkingLevelOptions = thinkingLevelOptionsFor(
|
const thinkingLevelOptions = thinkingLevelOptionsFor(
|
||||||
S.agent.thinkingLevelOptions,
|
S.agent.thinkingLevelOptions,
|
||||||
S.agent.defaultValue,
|
|
||||||
S.agent.thinkingLevelNoneKept,
|
S.agent.thinkingLevelNoneKept,
|
||||||
cfg.model?.thinkingLevel,
|
cfg.model?.thinkingLevel,
|
||||||
);
|
);
|
||||||
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
|
const compactionModeOptions = optionRows(S.agent.compactionModeOptions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -534,10 +588,8 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<OptionMenu
|
<ResettableOptionMenu
|
||||||
label={S.agent.thinkingLevel}
|
label={S.agent.thinkingLevel}
|
||||||
fullWidth
|
|
||||||
size="sm"
|
|
||||||
value={thinkingLevel}
|
value={thinkingLevel}
|
||||||
onChange={setThinkingLevel}
|
onChange={setThinkingLevel}
|
||||||
options={thinkingLevelOptions}
|
options={thinkingLevelOptions}
|
||||||
@@ -577,10 +629,8 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<OptionMenu
|
<ResettableOptionMenu
|
||||||
label={S.agent.compactionMode}
|
label={S.agent.compactionMode}
|
||||||
fullWidth
|
|
||||||
size="sm"
|
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={setMode}
|
onChange={setMode}
|
||||||
options={compactionModeOptions}
|
options={compactionModeOptions}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function thinkingLevelLabel(
|
|||||||
/** One dropdown row of the agent-settings thinking-level menu (shape of OptionMenuChoice<string>). */
|
/** One dropdown row of the agent-settings thinking-level menu (shape of OptionMenuChoice<string>). */
|
||||||
export interface ThinkingLevelOptionRow {
|
export interface ThinkingLevelOptionRow {
|
||||||
value: string;
|
value: string;
|
||||||
/** Compact text on the trigger button ("" renders the localized default tag). */
|
/** Compact text on the trigger button. */
|
||||||
triggerLabel: string;
|
triggerLabel: string;
|
||||||
/** Panel row title. */
|
/** Panel row title. */
|
||||||
label: string;
|
label: string;
|
||||||
@@ -51,25 +51,30 @@ export interface ThinkingLevelOptionRow {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Assembles the agent-settings thinking-level dropdown rows from the dictionary's
|
* Assembles the agent-settings thinking-level dropdown rows from the dictionary's
|
||||||
* [value, description] pairs (which no longer include `none`). Backward compatibility:
|
* [value, description] pairs, composing both review decisions:
|
||||||
* when the **persisted** config already stores `none` (a legacy value), a display-only
|
* - the "" (inherit) row is **filtered** — the menu offers only concrete tiers, the user
|
||||||
* row for it is appended — the trigger shows the real stored state instead of "—" and
|
* picks explicitly; an unset value shows the OptionMenu's (default) placeholder and the
|
||||||
* nothing is silently rewritten. Gating on the persisted value (not the local edit
|
* reset link next to the menu rewinds a local pick back to it;
|
||||||
* state) means a misclick onto another tier keeps the row until the change is actually
|
* - `none` is no longer offered (many models cannot disable thinking) but stays a valid
|
||||||
* saved, so the user can always click back to the value still stored on disk.
|
* stored value: when the **persisted** config already carries it, a display-only row is
|
||||||
|
* appended — the trigger shows the real stored state and nothing is silently rewritten.
|
||||||
|
* Gating on the persisted value (not the local edit state) means a misclick onto another
|
||||||
|
* tier keeps the row until the change is actually saved, so the user can always click
|
||||||
|
* back to the value still stored on disk.
|
||||||
*/
|
*/
|
||||||
export function thinkingLevelOptionsFor(
|
export function thinkingLevelOptionsFor(
|
||||||
options: ReadonlyArray<readonly [string, string]>,
|
options: ReadonlyArray<readonly [string, string]>,
|
||||||
defaultTag: string,
|
|
||||||
noneKeptDescription: string,
|
noneKeptDescription: string,
|
||||||
storedLevel: string | undefined,
|
storedLevel: string | undefined,
|
||||||
): ThinkingLevelOptionRow[] {
|
): ThinkingLevelOptionRow[] {
|
||||||
const rows = options.map(([value, description]) => ({
|
const rows = options
|
||||||
value,
|
.filter(([value]) => value !== "")
|
||||||
triggerLabel: value || defaultTag,
|
.map(([value, description]) => ({
|
||||||
label: value || defaultTag,
|
value,
|
||||||
description,
|
triggerLabel: value,
|
||||||
}));
|
label: value,
|
||||||
|
description,
|
||||||
|
}));
|
||||||
if (storedLevel === "none") {
|
if (storedLevel === "none") {
|
||||||
rows.push({
|
rows.push({
|
||||||
value: "none",
|
value: "none",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { Input } from "../../components/ui/input";
|
|||||||
import { PasswordInput } from "../../components/ui/password-input";
|
import { PasswordInput } from "../../components/ui/password-input";
|
||||||
import { Modal } from "../../components/ui/modal";
|
import { Modal } from "../../components/ui/modal";
|
||||||
import { Select } from "../../components/ui/select";
|
import { Select } from "../../components/ui/select";
|
||||||
|
import { Switch } from "../../components/ui/switch";
|
||||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Chevron } from "../../components/ui/chevron";
|
import { Chevron } from "../../components/ui/chevron";
|
||||||
@@ -1242,17 +1243,7 @@ function ModelDialog({
|
|||||||
{S.models.modelId}
|
{S.models.modelId}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-baseline gap-2.5">
|
<span className="flex shrink-0 items-baseline gap-2.5">
|
||||||
{/* Model homepage (the model's own page; gateway groups have per-model URLs) — only for existing rows, whose identity is settled. */}
|
{/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */}
|
||||||
{row && modelHomepageUrl(row.provider, row.modelId) && (
|
|
||||||
<a
|
|
||||||
href={modelHomepageUrl(row.provider, row.modelId)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
|
|
||||||
>
|
|
||||||
{S.models.homepage} ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{dialogProvider?.modelsUrl && (
|
{dialogProvider?.modelsUrl && (
|
||||||
<a
|
<a
|
||||||
href={dialogProvider.modelsUrl}
|
href={dialogProvider.modelsUrl}
|
||||||
@@ -1368,14 +1359,16 @@ function ModelDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header: logo + display name + badges + upstream id (existing model). */}
|
{/* Header: logo + display name + badges + upstream id (existing model); the model
|
||||||
|
homepage entry lives here as a small secondary button on the right (moved out of
|
||||||
|
the form body — it's a property of the model, not an input). */}
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<div className="flex items-center gap-2.5 rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800/60">
|
<div className="flex items-center gap-2.5 rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800/60">
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
provider={form.provider}
|
provider={form.provider}
|
||||||
className="h-6 w-6 shrink-0 text-gray-700 dark:text-gray-300"
|
className="h-6 w-6 shrink-0 text-gray-700 dark:text-gray-300"
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
<span className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||||
{form.displayName ?? form.modelId}
|
{form.displayName ?? form.modelId}
|
||||||
{isDefault && <Badge tone="brand">{S.models.default}</Badge>}
|
{isDefault && <Badge tone="brand">{S.models.default}</Badge>}
|
||||||
@@ -1389,6 +1382,31 @@ function ModelDialog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{row && modelHomepageUrl(row.provider, row.modelId) && (
|
||||||
|
<a
|
||||||
|
href={modelHomepageUrl(row.provider, row.modelId)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{S.models.homepage}
|
||||||
|
{/* External-link glyph (opens in a new tab) */}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1508,100 +1526,110 @@ function ModelDialog({
|
|||||||
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
|
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 3) Context window: unit on the right inside the input */}
|
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit
|
||||||
<label className="block">
|
sits inside each box as a muted right suffix. Placeholders cannot scroll, so at
|
||||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
this half width they carry only a short line; the full explanation lives in the
|
||||||
{S.models.contextWindow}
|
input's title (hover) — the owner explicitly prefers saving the vertical space
|
||||||
</span>
|
over a visible hint line. Only field errors appear under a cell. Max output
|
||||||
<span className="relative block">
|
tokens: per-model cap on the request's output — when set it wins over the
|
||||||
<Input
|
Agent's system_config value; empty inherits it (lets a small-context local
|
||||||
size="sm"
|
model stay under its window). */}
|
||||||
value={form.contextWindow}
|
<div className="grid grid-cols-2 items-start gap-2">
|
||||||
inputMode="numeric"
|
<label className="block">
|
||||||
disabled={!canEdit}
|
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||||
invalid={Boolean(fieldErrors.contextWindow)}
|
{S.models.contextWindow}
|
||||||
onChange={(e) => set({ contextWindow: digitsOnly(e.target.value) })}
|
|
||||||
className="pr-14 font-mono"
|
|
||||||
placeholder={
|
|
||||||
preset
|
|
||||||
? S.models.contextWindowHint
|
|
||||||
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
|
||||||
{S.models.contextWindowUnit}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="relative block">
|
||||||
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
|
<Input
|
||||||
</label>
|
size="sm"
|
||||||
|
value={form.contextWindow}
|
||||||
{/* 4) Max output tokens: per-model cap on the request's output — when set it wins
|
inputMode="numeric"
|
||||||
over the Agent's system_config value; empty inherits it. Lets a small-context
|
disabled={!canEdit}
|
||||||
local model stay under its window (the per-Agent default may not fit). */}
|
invalid={Boolean(fieldErrors.contextWindow)}
|
||||||
<label className="block">
|
onChange={(e) => set({ contextWindow: digitsOnly(e.target.value) })}
|
||||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
className="pr-12 font-mono"
|
||||||
{S.models.maxTokens}
|
// The title mirrors the placeholder: at half width the (EN) copy can clip, hover reveals it in full.
|
||||||
</span>
|
title={
|
||||||
<Input
|
preset
|
||||||
size="sm"
|
? S.models.contextWindowHint
|
||||||
value={form.maxTokens}
|
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
||||||
inputMode="numeric"
|
}
|
||||||
disabled={!canEdit}
|
placeholder={
|
||||||
invalid={Boolean(fieldErrors.maxTokens)}
|
preset
|
||||||
onChange={(e) => set({ maxTokens: digitsOnly(e.target.value) })}
|
? S.models.contextWindowHint
|
||||||
className="font-mono"
|
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
||||||
placeholder={S.models.maxTokensHint}
|
}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.maxTokens ? (
|
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||||
<FieldError text={fieldErrors.maxTokens} />
|
{S.models.tokenUnit}
|
||||||
) : (
|
</span>
|
||||||
<span className="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{S.models.maxTokensCapHint}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block">
|
||||||
{/* 5) Pricing: three fields side by side; currency and unit (/M tok) both
|
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||||
shown inside the input, no need to repeat in the title. */}
|
{S.models.maxTokens}
|
||||||
<div>
|
</span>
|
||||||
<p className="mb-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400">
|
<span className="relative block">
|
||||||
{S.models.pricing}
|
<Input
|
||||||
</p>
|
size="sm"
|
||||||
{/* Errors land right under the offending field (which is also outlined red): with three fields side by side, only sticking close to the field makes clear which one it is. */}
|
value={form.maxTokens}
|
||||||
<div className="grid grid-cols-3 items-start gap-2">
|
inputMode="numeric"
|
||||||
{(
|
disabled={!canEdit}
|
||||||
[
|
invalid={Boolean(fieldErrors.maxTokens)}
|
||||||
["cacheRead", S.models.priceCacheRead, form.cacheRead],
|
onChange={(e) => set({ maxTokens: digitsOnly(e.target.value) })}
|
||||||
["cacheWrite", S.models.priceCacheWrite, form.cacheWrite],
|
className="pr-12 font-mono"
|
||||||
["output", S.models.priceOutput, form.output],
|
// Short placeholder (fits the half-width box); the full explanation incl. the small-context advice is the hover title.
|
||||||
] as Array<[keyof FieldErrors & keyof RowState, string, string]>
|
title={S.models.maxTokensTitle}
|
||||||
).map(([key, label, value]) => (
|
placeholder={S.models.maxTokensHint}
|
||||||
<label key={key} className="block">
|
/>
|
||||||
<span className="mb-1 block text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||||
<span className="relative block">
|
{S.models.tokenUnit}
|
||||||
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
|
</span>
|
||||||
{CURRENCY_SYMBOL[currency]}
|
</span>
|
||||||
</span>
|
{fieldErrors.maxTokens && <FieldError text={fieldErrors.maxTokens} />}
|
||||||
<Input
|
</label>
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
inputMode="decimal"
|
|
||||||
disabled={!canEdit}
|
|
||||||
invalid={Boolean(fieldErrors[key])}
|
|
||||||
onChange={(e) => set({ [key]: decimalOnly(e.target.value) })}
|
|
||||||
className="pl-4 pr-11 text-right font-mono"
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
|
||||||
{S.models.priceUnitShort}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 6) Identity: model id (renamable) + display name and group (side by side) */}
|
{/* 4) Pricing: three fields side by side with self-contained labels (… price) — no
|
||||||
|
standalone section heading; currency and unit (/M tok) are shown inside the input.
|
||||||
|
Errors land right under the offending field (which is also outlined red): with
|
||||||
|
three fields side by side, only sticking close to the field makes clear which one it is. */}
|
||||||
|
<div className="grid grid-cols-3 items-start gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["cacheRead", S.models.priceCacheRead, form.cacheRead],
|
||||||
|
["cacheWrite", S.models.priceCacheWrite, form.cacheWrite],
|
||||||
|
["output", S.models.priceOutput, form.output],
|
||||||
|
] as Array<[keyof FieldErrors & keyof RowState, string, string]>
|
||||||
|
).map(([key, label, value]) => (
|
||||||
|
<label key={key} className="block">
|
||||||
|
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="relative block">
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
|
||||||
|
{CURRENCY_SYMBOL[currency]}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
inputMode="decimal"
|
||||||
|
disabled={!canEdit}
|
||||||
|
invalid={Boolean(fieldErrors[key])}
|
||||||
|
onChange={(e) => set({ [key]: decimalOnly(e.target.value) })}
|
||||||
|
className="pl-4 pr-11 text-right font-mono"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||||
|
{S.models.priceUnitShort}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5) Identity: model id (renamable) + display name and group (side by side) */}
|
||||||
{!isNew && identityFields}
|
{!isNew && identityFields}
|
||||||
{/* Legacy entries carrying a non-openai client_type (historical config): read-only display. */}
|
{/* Legacy entries carrying a non-openai client_type (historical config): read-only display. */}
|
||||||
{!isNew && !preset && form.clientType && form.clientType !== "openai" && (
|
{!isNew && !preset && form.clientType && form.clientType !== "openai" && (
|
||||||
@@ -1610,25 +1638,31 @@ function ModelDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vision capability: for preset models it's flagged by the built-in catalog (read-only); custom models can be checked. */}
|
{/* Vision capability: for preset models it's flagged by the built-in catalog (read-only);
|
||||||
|
custom models toggle it here — an iOS-style switch sitting inline right next to the
|
||||||
|
label (per owner: no full-row stretch, no standing explanation text). Only the OFF
|
||||||
|
state shows one small muted line: images are then read via the configured vision
|
||||||
|
proxy model (describe_image). */}
|
||||||
{!preset && (
|
{!preset && (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className={`flex items-center gap-2 text-sm ${canEdit ? "cursor-pointer" : "cursor-not-allowed"}`}
|
className={`inline-flex items-center gap-2 ${canEdit ? "cursor-pointer" : "cursor-not-allowed"}`}
|
||||||
>
|
>
|
||||||
<input
|
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||||
type="checkbox"
|
{S.models.vision}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
checked={form.vision}
|
checked={form.vision}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onChange={(e) => set({ vision: e.target.checked })}
|
onChange={(vision) => set({ vision })}
|
||||||
className={canEdit ? "cursor-pointer" : "cursor-not-allowed"}
|
aria-label={S.models.vision}
|
||||||
/>
|
/>
|
||||||
{S.models.vision}
|
|
||||||
</label>
|
</label>
|
||||||
{/* Note toggles with the checkbox: supports images → read_image feeds them back directly; doesn't → describe_image reads them on its behalf. */}
|
{!form.vision && (
|
||||||
<p className="mt-1 text-xs text-gray-400">
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{form.vision ? S.models.visionOnHint : S.models.visionOffHint}
|
{S.models.visionOffProxyHint}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export const en: Strings = {
|
|||||||
toolMaxOutput: "maxOutputLength",
|
toolMaxOutput: "maxOutputLength",
|
||||||
mcpServers: "MCP Servers (read-only)",
|
mcpServers: "MCP Servers (read-only)",
|
||||||
defaultValue: "(default)",
|
defaultValue: "(default)",
|
||||||
|
resetToDefault: "Reset to default",
|
||||||
deleteAgent: "Delete Agent",
|
deleteAgent: "Delete Agent",
|
||||||
builtinUndeletable: "Built-in Agents cannot be deleted",
|
builtinUndeletable: "Built-in Agents cannot be deleted",
|
||||||
deleteConfirm: (name: string): string =>
|
deleteConfirm: (name: string): string =>
|
||||||
@@ -317,16 +318,16 @@ export const en: Strings = {
|
|||||||
displayNameHint: "Defaults to the model ID",
|
displayNameHint: "Defaults to the model ID",
|
||||||
providerGroup: "Group",
|
providerGroup: "Group",
|
||||||
contextWindow: "Context window",
|
contextWindow: "Context window",
|
||||||
contextWindowUnit: "tokens",
|
tokenUnit: "Token",
|
||||||
contextWindowHint: "Leave empty if unknown",
|
contextWindowHint: "Leave empty if unknown",
|
||||||
maxTokens: "Max output tokens",
|
maxTokens: "Max output tokens",
|
||||||
maxTokensHint: "Leave empty to inherit the agent setting",
|
maxTokensHint: "Empty = inherit agent setting",
|
||||||
maxTokensCapHint: "Caps output tokens per request; lower it for small-context local models",
|
maxTokensTitle:
|
||||||
|
"Caps output tokens per request; leave empty to inherit the agent setting — lower it for small-context models",
|
||||||
maxTokensInvalid: "Must be a positive integer",
|
maxTokensInvalid: "Must be a positive integer",
|
||||||
clientTypeLocked: (t: string): string => `Protocol: ${t} (kept as configured; not editable)`,
|
clientTypeLocked: (t: string): string => `Protocol: ${t} (kept as configured; not editable)`,
|
||||||
vision: "Supports image input (vision / multimodal)",
|
vision: "Vision support",
|
||||||
visionHint:
|
visionOffProxyHint: "Images are read via the vision proxy model",
|
||||||
"When unchecked, chat images are passed as file paths and read via describe_image (vision model)",
|
|
||||||
visionBadge: "Vision",
|
visionBadge: "Vision",
|
||||||
visionModelBadge: "Proxy vision",
|
visionModelBadge: "Proxy vision",
|
||||||
setVisionModel: "Set as proxy vision model",
|
setVisionModel: "Set as proxy vision model",
|
||||||
@@ -337,10 +338,9 @@ export const en: Strings = {
|
|||||||
testOk: (ms: number): string => `Connected (${ms} ms)`,
|
testOk: (ms: number): string => `Connected (${ms} ms)`,
|
||||||
testFailed: (msg: string): string => `Failed: ${msg}`,
|
testFailed: (msg: string): string => `Failed: ${msg}`,
|
||||||
modelIdRenameHint: "Renaming migrates the existing config and API key",
|
modelIdRenameHint: "Renaming migrates the existing config and API key",
|
||||||
priceCacheRead: "Cache read",
|
priceCacheRead: "Cache read price",
|
||||||
priceCacheWrite: "Cache write",
|
priceCacheWrite: "Cache write price",
|
||||||
priceOutput: "Output",
|
priceOutput: "Output price",
|
||||||
pricing: "Pricing",
|
|
||||||
priceUnit: "per 1M tokens",
|
priceUnit: "per 1M tokens",
|
||||||
currency: "Currency",
|
currency: "Currency",
|
||||||
currencyUsd: "USD $",
|
currencyUsd: "USD $",
|
||||||
@@ -358,9 +358,6 @@ export const en: Strings = {
|
|||||||
baseUrlHint: "Leave empty to use the provider default",
|
baseUrlHint: "Leave empty to use the provider default",
|
||||||
baseUrlRequired: "A base URL is required",
|
baseUrlRequired: "A base URL is required",
|
||||||
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
|
contextWindowDefaultHint: (n: number): string => `Defaults to ${n} if empty`,
|
||||||
visionOnHint: "Images are read with read_image and fed to the model directly",
|
|
||||||
visionOffHint:
|
|
||||||
"Images are read with describe_image: described by the proxy vision model, text only; chat uploads are passed as file paths",
|
|
||||||
confirmDeleteTitle: "Delete model",
|
confirmDeleteTitle: "Delete model",
|
||||||
confirmDelete: (name: string): string =>
|
confirmDelete: (name: string): string =>
|
||||||
`Delete "${name}"? Its configuration and API key will be removed.`,
|
`Delete "${name}"? Its configuration and API key will be removed.`,
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ export const zh = {
|
|||||||
toolMaxOutput: "maxOutputLength",
|
toolMaxOutput: "maxOutputLength",
|
||||||
mcpServers: "MCP Server(只读)",
|
mcpServers: "MCP Server(只读)",
|
||||||
defaultValue: "(缺省)",
|
defaultValue: "(缺省)",
|
||||||
|
/** Reset link next to the runtime dropdowns: rewinds the local pick back to "not overridden" (the menus offer no inherit row). */
|
||||||
|
resetToDefault: "恢复缺省",
|
||||||
deleteAgent: "删除 Agent",
|
deleteAgent: "删除 Agent",
|
||||||
builtinUndeletable: "内置 Agent 不可被删除",
|
builtinUndeletable: "内置 Agent 不可被删除",
|
||||||
deleteConfirm: (name: string): string =>
|
deleteConfirm: (name: string): string =>
|
||||||
@@ -292,16 +294,20 @@ export const zh = {
|
|||||||
displayNameHint: "留空则展示模型 ID",
|
displayNameHint: "留空则展示模型 ID",
|
||||||
providerGroup: "分组",
|
providerGroup: "分组",
|
||||||
contextWindow: "上下文窗口",
|
contextWindow: "上下文窗口",
|
||||||
contextWindowUnit: "tokens",
|
/** 单位后缀,显示在上下文窗口/最大输出长度输入框内右侧。 */
|
||||||
|
tokenUnit: "Token",
|
||||||
contextWindowHint: "留空表示未知",
|
contextWindowHint: "留空表示未知",
|
||||||
maxTokens: "最大输出长度(Token)",
|
maxTokens: "最大输出长度",
|
||||||
|
/** Placeholders cannot scroll, so this must fit the half-width box; the full guidance is the input's title tooltip (the owner prefers no visible hint line — saves vertical space). */
|
||||||
maxTokensHint: "留空沿用 Agent 设置",
|
maxTokensHint: "留空沿用 Agent 设置",
|
||||||
maxTokensCapHint: "限制单次请求的输出 Token 数;小上下文的本地模型建议调低",
|
maxTokensTitle:
|
||||||
|
"按模型限制单次请求的最大输出 Token 数;留空沿用 Agent 设置,小上下文模型建议调低",
|
||||||
maxTokensInvalid: "必须为正整数",
|
maxTokensInvalid: "必须为正整数",
|
||||||
clientTypeLocked: (t: string): string => `协议:${t}(沿用原配置,不可修改)`,
|
clientTypeLocked: (t: string): string => `协议:${t}(沿用原配置,不可修改)`,
|
||||||
vision: "支持图片输入(视觉/多模态)",
|
/** Switch label only — the dialog carries no explanation text for it (per owner). */
|
||||||
visionHint:
|
vision: "支持视觉",
|
||||||
"取消勾选后,对话中上传的图片将以文件路径转交,读图经 describe_image 由视觉模型代读",
|
/** Shown only while the vision switch is OFF: images are then read via the configured vision proxy model (describe_image). */
|
||||||
|
visionOffProxyHint: "使用视觉代理模型读图",
|
||||||
visionBadge: "视觉",
|
visionBadge: "视觉",
|
||||||
visionModelBadge: "视觉代理",
|
visionModelBadge: "视觉代理",
|
||||||
setVisionModel: "设为视觉代理模型",
|
setVisionModel: "设为视觉代理模型",
|
||||||
@@ -312,10 +318,9 @@ export const zh = {
|
|||||||
testOk: (ms: number): string => `连通正常(${ms} ms)`,
|
testOk: (ms: number): string => `连通正常(${ms} ms)`,
|
||||||
testFailed: (msg: string): string => `连通失败:${msg}`,
|
testFailed: (msg: string): string => `连通失败:${msg}`,
|
||||||
modelIdRenameHint: "改 id 后原配置与 API key 一并迁移",
|
modelIdRenameHint: "改 id 后原配置与 API key 一并迁移",
|
||||||
priceCacheRead: "缓存读取",
|
priceCacheRead: "缓存读取价格",
|
||||||
priceCacheWrite: "缓存写入",
|
priceCacheWrite: "缓存写入价格",
|
||||||
priceOutput: "输出",
|
priceOutput: "输出价格",
|
||||||
pricing: "价格",
|
|
||||||
priceUnit: "每百万 Token",
|
priceUnit: "每百万 Token",
|
||||||
currency: "币种",
|
currency: "币种",
|
||||||
currencyUsd: "美元 $",
|
currencyUsd: "美元 $",
|
||||||
@@ -333,9 +338,6 @@ export const zh = {
|
|||||||
baseUrlHint: "留空使用厂商默认地址",
|
baseUrlHint: "留空使用厂商默认地址",
|
||||||
baseUrlRequired: "必须填写 base URL",
|
baseUrlRequired: "必须填写 base URL",
|
||||||
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
|
contextWindowDefaultHint: (n: number): string => `留空按 ${n} 计`,
|
||||||
visionOnHint: "读图用 read_image:图片直接回灌模型",
|
|
||||||
visionOffHint:
|
|
||||||
"读图用 describe_image:图片交给视觉代理模型描述、只回文本;对话中上传的图片以文件路径转交",
|
|
||||||
confirmDeleteTitle: "删除模型",
|
confirmDeleteTitle: "删除模型",
|
||||||
confirmDelete: (name: string): string =>
|
confirmDelete: (name: string): string =>
|
||||||
`确定删除「${name}」?该模型的配置与 API key 将一并移除。`,
|
`确定删除「${name}」?该模型的配置与 API key 将一并移除。`,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* agent-settings-page.ts optionRows unit tests: the runtime dropdowns build from the
|
||||||
|
* dictionary's [value, description] pairs with the "" (inherit) row dropped and dictionary
|
||||||
|
* order kept — pinned so a future reconciliation (e.g. with the no-none branch's option
|
||||||
|
* assembly) cannot silently regress the filter.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { optionRows } from "../src/features/agents/agent-settings-page";
|
||||||
|
|
||||||
|
const ENTRIES: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
["", "Send no override."],
|
||||||
|
["low", "Low tier."],
|
||||||
|
["medium", "Medium tier."],
|
||||||
|
["high", "High tier."],
|
||||||
|
["xhigh", "Extra-high tier."],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("optionRows", () => {
|
||||||
|
it("drops the '' inherit row and keeps dictionary order", () => {
|
||||||
|
const rows = optionRows(ENTRIES);
|
||||||
|
expect(rows.map((r) => r.value)).toEqual(["low", "medium", "high", "xhigh"]);
|
||||||
|
expect(rows.some((r) => r.value === "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps value into both labels and carries the description through", () => {
|
||||||
|
const rows = optionRows([["summarize", "Summarize old context."]]);
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
value: "summarize",
|
||||||
|
triggerLabel: "summarize",
|
||||||
|
label: "summarize",
|
||||||
|
description: "Summarize old context.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,18 +63,22 @@ describe("thinkingLevelOptionsFor (agent-settings dropdown assembly)", () => {
|
|||||||
["xhigh", "Extra-high tier."],
|
["xhigh", "Extra-high tier."],
|
||||||
];
|
];
|
||||||
|
|
||||||
it("maps the dictionary in order; the '' row renders the default tag; no none row normally", () => {
|
it("filters the '' inherit row and keeps dictionary order; no none row normally", () => {
|
||||||
for (const stored of [undefined, "", "medium", "xhigh"]) {
|
for (const stored of [undefined, "", "medium", "xhigh"]) {
|
||||||
const rows = thinkingLevelOptionsFor(OPTIONS, "(default)", "legacy none", stored);
|
const rows = thinkingLevelOptionsFor(OPTIONS, "legacy none", stored);
|
||||||
expect(rows.map((r) => r.value)).toEqual(["", "low", "medium", "high", "xhigh"]);
|
expect(rows.map((r) => r.value)).toEqual(["low", "medium", "high", "xhigh"]);
|
||||||
expect(rows[0]).toMatchObject({ triggerLabel: "(default)", label: "(default)" });
|
|
||||||
expect(rows.some((r) => r.value === "none")).toBe(false);
|
expect(rows.some((r) => r.value === "none")).toBe(false);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
triggerLabel: "low",
|
||||||
|
label: "low",
|
||||||
|
description: "Low tier.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends a display-only none row when the persisted config stores none (backward compat)", () => {
|
it("appends a display-only none row when the persisted config stores none (backward compat)", () => {
|
||||||
const rows = thinkingLevelOptionsFor(OPTIONS, "(default)", "legacy none", "none");
|
const rows = thinkingLevelOptionsFor(OPTIONS, "legacy none", "none");
|
||||||
expect(rows.map((r) => r.value)).toEqual(["", "low", "medium", "high", "xhigh", "none"]);
|
expect(rows.map((r) => r.value)).toEqual(["low", "medium", "high", "xhigh", "none"]);
|
||||||
expect(rows.at(-1)).toEqual({
|
expect(rows.at(-1)).toEqual({
|
||||||
value: "none",
|
value: "none",
|
||||||
triggerLabel: "none",
|
triggerLabel: "none",
|
||||||
|
|||||||
Reference in new issue
Block a user