feat(workspace,settings): model menu chip in the composer, compact settings panels

The workspace home's composer takes ONE Model chip (ModelMenu: model list
plus Effort) and moves AI Router / Permission / Local-New worktree into the
row under it; Settings drops the General-page cost card and export row,
PricingSettings and the empty states are compacted for settings panels.
This commit is contained in:
2026-10-02 17:49:05 +07:00
parent da1b17778c
commit fadecaddd0
23 changed files with 925 additions and 640 deletions
@@ -663,6 +663,7 @@ export function AlertsNotifications() {
icon={ListChecks}
title={t("rules.empty")}
description={ts("alertsHub.rulesEmptyHint")}
compact
/>
) : (
<ul className="space-y-2">
@@ -766,6 +767,7 @@ export function AlertsNotifications() {
icon={unackedOnly ? BellOff : BellRing}
title={t("feed.emptyTitle")}
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
compact
/>
) : (
<>
+10 -4
View File
@@ -75,20 +75,26 @@ export interface EmptyStateProps {
description: string;
/** Optional CTA rendered below the description (button, link, or form). */
action?: React.ReactNode;
/** Inside a card (a settings panel) rather than a whole page: less padding. */
compact?: boolean;
}
/**
* Renders a vertically centered empty state inside the main content column.
* @param props See {@link EmptyStateProps}.
*/
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
export function EmptyState({ icon: Icon, title, description, action, compact }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
<div
className={`flex flex-col items-center justify-center text-center ${compact ? "py-8" : "py-20"}`}
>
<div
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
>
<Icon className="w-6 h-6 text-fg-muted" />
</div>
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
{action}
</div>
);
+110 -94
View File
@@ -2,11 +2,12 @@
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the ONE place a session is started, its
* fields in order: AI (the session's own tool, the workspace's by default),
* AI Router (Native or a profile), Model (the tool's own list, or the
* router's models), Permission (Claude's mode, Codex's sandbox/approval, a
* catalog tool's bypass) and Effort — the workspace's Recents — the sessions it
* then opens it (`&run=<runId>`) — the ONE place a session is started. Inside
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
* router's models, plus Effort); under it the quieter choices — AI Router
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
* then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
@@ -34,7 +35,6 @@ import {
} from "lucide-react";
import {
api,
RUN_EFFORT_CHOICES,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
@@ -48,7 +48,7 @@ import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
@@ -414,7 +414,7 @@ function HomeBody({
onImagesChange={setImages}
bare
/>
<div className="mt-1 flex flex-wrap items-center gap-0.5">
<div className="mt-1 flex items-center gap-1">
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
@@ -436,92 +436,14 @@ function HomeBody({
}))}
/>
</div>
<RouterPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
/>
{hasModel && (
<ModelPicker
variant="ghost"
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
/>
)}
{claude && (
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
onChange={setMode}
options={[
{ value: "auto", label: t("run:fields.permissionAuto") },
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
{ value: "default", label: t("run:fields.permissionDefault") },
{ value: "plan", label: t("run:fields.permissionPlan") },
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
)}
{codex && (
<>
<Select<CodexSandbox>
variant="ghost"
label={t("run:fields.sandbox")}
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
<Select<CodexApproval>
variant="ghost"
label={t("run:fields.approval")}
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</>
)}
{hasEffort && (
<Select<EffortLevel>
variant="ghost"
label={t("run:fields.effort")}
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
)}
<div className="ml-auto flex items-center gap-3 pl-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
labelClassName="text-xs text-fg-secondary"
<div className="ml-auto flex items-center gap-1">
{hasModel && (
<ModelMenu
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/>
)}
<button
@@ -538,6 +460,100 @@ function HomeBody({
</div>
</div>
</div>
{/* The session's quieter choices, under the box like Claude Desktop's. */}
<div
data-testid="workspace-home-options"
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
>
<RouterPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
/>
{claude && (
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
onChange={setMode}
options={(
[
["auto", "Auto"],
["acceptEdits", "AcceptEdits"],
["default", "Default"],
["plan", "Plan"],
["bypassPermissions", "Bypass"],
] as const
).map(([value, key]) => ({
value,
label: t(`home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
)}
{codex && (
<>
<Select<CodexSandbox>
variant="ghost"
label={t("run:fields.sandbox")}
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
<Select<CodexApproval>
variant="ghost"
label={t("run:fields.approval")}
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</>
)}
{claude && project?.root && (
<div data-testid="workspace-home-worktree">
<Select<"local" | "worktree">
variant="ghost"
label={t("home.worktreeHint")}
value={inWorktree ? "worktree" : "local"}
onChange={(v) => setInWorktree(v === "worktree")}
options={[
{
value: "local",
label: t("home.local"),
hint: t("home.localHint"),
icon: <Folder className="h-3.5 w-3.5" />,
},
{
value: "worktree",
label: t("home.worktree"),
hint: t("home.worktreeHint"),
icon: <GitBranch className="h-3.5 w-3.5" />,
},
]}
/>
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
</span>
)}
</div>
{danger && (
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
@@ -2,8 +2,8 @@
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, on the AI tool picked for it (the workspace's by
* default), with its fields in order — AI, AI Router, Model (the router's
* models on a router), Permission, Effort —, all-busy is refused, Recents open a live pane or the
* default), AI and Model in the box and AI Router / Permission / Worktree
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens
@@ -32,7 +32,7 @@ vi.mock("../../run/RunSetup", () => ({
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
ModelPicker: ({
ModelMenu: ({
value,
onChange,
choices,
@@ -210,14 +210,21 @@ describe("WorkspaceHome", () => {
);
});
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const box = screen.getByTestId("workspace-home-composer");
const labels = [
...box.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
].map((x) => x.getAttribute("aria-label"));
expect(labels).toEqual(["AI", "AI Router", "model", "Permission mode", "Thinking effort"]);
const labels = (id: string) =>
[
...screen
.getByTestId(id)
.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
].map((x) => x.getAttribute("aria-label"));
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
expect(labels("workspace-home-options")).toEqual([
"AI Router",
"Permission mode",
expect.stringContaining("own git worktree"),
]);
});
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
@@ -282,11 +289,14 @@ describe("WorkspaceHome", () => {
);
});
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
it("starts the session in its own worktree when New worktree is picked (a repo only)", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
await user.click(
within(await screen.findByTestId("workspace-home-worktree")).getByRole("button")
);
await user.click(screen.getByText("New worktree"));
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
+275 -23
View File
@@ -9,15 +9,35 @@
* (`POST /api/run/drop-image`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Router's models), plus Custom….
* or a fixed one (an AI Router's models), plus Custom…;
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
* back to the model's own default).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, GitBranch, Home, History as HistoryIcon, X } from "lucide-react";
import { api, RUN_MODEL_CHOICES, type ModelChoice } from "../../lib/api";
import {
Check,
ChevronDown,
ChevronRight,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
RotateCcw,
X,
} from "lucide-react";
import {
api,
RUN_EFFORT_CHOICES,
RUN_MODEL_CHOICES,
type EffortLevel,
type ModelChoice,
} from "../../lib/api";
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types";
import { Select } from "../Select";
@@ -613,24 +633,14 @@ export function CwdAutocomplete({
// the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__";
export function ModelPicker({
value,
onChange,
tool,
choices: fixed,
variant,
}: {
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
const { t } = useTranslation("run");
// The tool's own list (Codex's models cache, Claude's aliases named after
// the newest version, a catalog tool's vendor rows); Claude's static
// aliases only when the server cannot answer.
/**
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
* own list (Codex's models cache, Claude's aliases named after the newest
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
* Claude's static aliases only when the server cannot answer. `loaded` once
* the list is final.
*/
function useModelChoices(tool: LaneTool, fixed?: ModelChoice[]) {
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
useEffect(() => {
let live = true;
@@ -649,11 +659,29 @@ export function ModelPicker({
if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fixed, fetched, tool]);
return { choices, loaded: fixed !== undefined || fetched !== null };
}
export function ModelPicker({
value,
onChange,
tool,
choices: fixed,
variant,
}: {
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
const { t } = useTranslation("run");
const { choices, loaded } = useModelChoices(tool, fixed);
// "Custom" is selected when the value isn't one of the listed IDs — judged
// only once the list has arrived, so a listed default never flashes as custom.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom =
(fixed !== undefined || fetched !== null) && value !== "" && !knownIds.includes(value);
const isCustom = loaded && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the
@@ -705,3 +733,227 @@ export function ModelPicker({
</div>
);
}
/** {@link ModelPicker} + the effort picker folded into one chip and popover. */
export function ModelMenu({
value,
onChange,
tool,
choices: fixed,
effort,
onEffortChange,
}: {
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
choices?: ModelChoice[];
/** Omitted for a tool that takes no effort. */
effort?: EffortLevel;
onEffortChange?: (e: EffortLevel) => void;
}) {
const { t } = useTranslation("run");
const { choices } = useModelChoices(tool, fixed);
const [open, setOpen] = useState(false);
const [more, setMore] = useState(false);
const [custom, setCustom] = useState(false);
const [up, setUp] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
// Below the chip unless the viewport has more room above it.
const r = ref.current?.getBoundingClientRect();
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", away);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", away);
document.removeEventListener("keydown", esc);
};
}, [open]);
const label = (c: ModelChoice) => (c.id === "" ? t("fields.modelInheritLabel") : c.label);
// Claude's aliases carry no hint from the server; the static list's apply.
const hint = (c: ModelChoice) => c.hint ?? RUN_MODEL_CHOICES.find((x) => x.id === c.id)?.hint;
// Claude: the aliases up front, the pinned versions under More models.
const pinned = (c: ModelChoice) => tool === "claude" && c.id.startsWith("claude-");
const main = choices.filter((c) => !pinned(c));
const rest = choices.filter(pinned);
const current = choices.find((c) => c.id === value);
const effortLabel = RUN_EFFORT_CHOICES.find((c) => c.id === effort && c.id)?.label;
const pick = (id: string) => {
onChange(id);
setOpen(false);
};
const row = (c: ModelChoice) => (
<button
key={c.id || "__default__"}
type="button"
role="menuitemradio"
aria-checked={c.id === value}
onClick={() => pick(c.id)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left hover:bg-surface-3"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-fg-primary">{label(c)}</span>
{hint(c) && <span className="block truncate text-[11px] text-fg-muted">{hint(c)}</span>}
</span>
{c.id === value && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
</button>
);
return (
<div ref={ref} className="relative" data-testid="model-menu">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
title={t("fields.model")}
onClick={() => setOpen((v) => !v)}
className="flex max-w-[16rem] items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<span className="truncate text-fg-primary">
{current ? label(current) : value || t("fields.modelInheritLabel")}
</span>
{effortLabel && <span className="shrink-0 text-fg-muted">{effortLabel}</span>}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
</button>
{open && (
<div
role="menu"
className={`absolute right-0 z-30 ${up ? "bottom-full mb-1" : "top-full mt-1"} max-h-[70vh] w-72 overflow-auto rounded-xl border border-border bg-surface-1 p-1 shadow-lg shadow-black/40`}
>
{main.map(row)}
{(!!rest.length || !fixed) && <div className="my-1 border-t border-border" />}
{!!rest.length && (
<>
<button
type="button"
onClick={() => setMore((v) => !v)}
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("fields.moreModels")}
<ChevronRight
className={`h-3.5 w-3.5 transition-transform ${more ? "rotate-90" : ""}`}
/>
</button>
{more && rest.map(row)}
</>
)}
{custom ? (
<input
autoFocus
type="text"
defaultValue={current ? "" : value}
placeholder={t("fields.modelCustomPlaceholder")}
autoComplete="off"
spellCheck={false}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
setCustom(false);
pick(e.currentTarget.value.trim());
}}
className="m-1 w-[calc(100%-0.5rem)] rounded-md border border-border bg-surface-2 px-2 py-1 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
/>
) : (
<button
type="button"
onClick={() => setCustom(true)}
className="flex w-full rounded-md px-2.5 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("fields.modelCustom")}
</button>
)}
{onEffortChange && (
<>
<div className="my-1 border-t border-border" />
<EffortSlider value={effort ?? ""} onChange={onEffortChange} />
</>
)}
</div>
)}
</div>
);
}
/** Low → Max as one slider, Claude Desktop's; no level picked = the model's own default. */
function EffortSlider({
value,
onChange,
}: {
value: EffortLevel;
onChange: (e: EffortLevel) => void;
}) {
const { t } = useTranslation("run");
const levels = RUN_EFFORT_CHOICES.filter((c) => c.id);
const index = levels.findIndex((c) => c.id === value);
const level = levels[index];
const set = levels.length > 1 && !!level;
const pct = set ? (index / (levels.length - 1)) * 100 : 0;
const pick = (i: number) => levels[i] && onChange(levels[i].id);
return (
<div className="px-2.5 pb-2 pt-1" data-testid="effort-slider">
<div className="flex items-center gap-2 text-xs">
<span className="text-fg-muted">{t("fields.effort")}</span>
<span className="flex-1 text-fg-primary">
{level ? level.label : t("fields.effortDefault")}
</span>
{set && (
<button
type="button"
title={t("fields.effortReset")}
aria-label={t("fields.effortReset")}
onClick={() => onChange("")}
className="rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="mt-2 flex justify-between text-[11px] text-fg-muted">
<span>{t("fields.effortFaster")}</span>
<span>{t("fields.effortSmarter")}</span>
</div>
<div className="relative mt-1 h-6">
{/* The track, its fill up to the thumb, and a dot per level. */}
<div className="absolute inset-0 overflow-hidden rounded-full bg-surface-3">
{set && (
<div
className="h-full rounded-full bg-accent/30"
style={{ width: `calc(0.75rem + (100% - 1.5rem) * ${pct / 100})` }}
/>
)}
</div>
<div className="pointer-events-none absolute inset-x-3 top-1/2 flex -translate-y-1/2 justify-between">
{levels.map((c) => (
<span key={c.id} className="h-1 w-1 rounded-full bg-fg-muted/60" />
))}
</div>
<input
type="range"
min={0}
max={levels.length - 1}
step={1}
// Unset: no thumb, and a click anywhere picks that level (onClick,
// because onChange stays silent when the click lands on this value).
value={set ? index : 0}
aria-label={t("fields.effort")}
aria-valuetext={level ? level.label : t("fields.effortDefault")}
onChange={(e) => pick(Number(e.currentTarget.value))}
onClick={(e) => pick(Number(e.currentTarget.value))}
className={`absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent focus:outline-none [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-fg-primary [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-fg-primary [&::-webkit-slider-thumb]:shadow focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-accent/50 ${
set ? "" : "[&::-moz-range-thumb]:opacity-0 [&::-webkit-slider-thumb]:opacity-0"
}`}
/>
</div>
<p className="mt-1.5 text-[11px] text-fg-muted">
{level ? level.hint : t("fields.effortDefaultHint")}
</p>
</div>
);
}
@@ -5,7 +5,9 @@
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one,
* and Custom…, which is a sentinel, never a model id.
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
* same list as one chip: aliases up front, pinned versions under More models,
* plus the effort slider.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,7 +16,13 @@ import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../RunSetup";
import {
ModelMenu,
ModelPicker,
PromptEditor,
promptWithImages,
type PromptImage,
} from "../RunSetup";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -217,3 +225,24 @@ describe("ModelPicker", () => {
expect(screen.queryByText("Sonnet (latest · 5.5)")).toBeNull();
});
});
describe("ModelMenu", () => {
it("puts the aliases up front, pinned versions under More models, and sets the effort", async () => {
const onChange = vi.fn();
const onEffort = vi.fn();
render(
<ModelMenu value="" onChange={onChange} tool="claude" effort="" onEffortChange={onEffort} />
);
fireEvent.click(screen.getByTitle(i18n.t("run:fields.model")));
await screen.findByText("Sonnet (latest · 5.5)");
expect(screen.queryByText("Claude Sonnet 5.5")).toBeNull();
// Low, Medium, High, … — index 2 is High.
fireEvent.change(screen.getByRole("slider", { name: i18n.t("run:fields.effort") }), {
target: { value: "2" },
});
expect(onEffort).toHaveBeenCalledWith("high");
fireEvent.click(screen.getByText(i18n.t("run:fields.moreModels")));
fireEvent.click(screen.getByText("Claude Sonnet 5.5"));
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
});
});
+167 -163
View File
@@ -195,13 +195,15 @@ interface Props {
allowReset?: boolean;
/** `<section id>` — a deep-link target. */
id?: string;
/** The Pricing page's per-vendor heading; its long description is shown once, by the page. */
heading?: string;
}
/**
* The pricing table filtered to one vendor's rows.
* @returns The section, or a skeleton while the rules load.
*/
export function PricingSettings({ vendor, allowReset = false, id = "pricing" }: Props) {
export function PricingSettings({ vendor, allowReset = false, id = "pricing", heading }: Props) {
const { t } = useTranslation("settings");
const [pricing, setPricing] = useState<ModelPricing[]>([]);
const [loading, setLoading] = useState(true);
@@ -378,7 +380,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
const renderEditCells = () => (
<>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="text"
value={editRow.model_pattern}
@@ -389,7 +391,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
autoFocus={adding}
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="text"
value={editRow.display_name}
@@ -398,7 +400,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -408,7 +410,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -418,7 +420,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -428,7 +430,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -438,7 +440,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -448,7 +450,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -458,7 +460,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<input
type="number"
step="0.01"
@@ -468,7 +470,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-4 py-3">
<td className="px-3 py-3">
<div className="flex items-center gap-1">
<button
onClick={saveEdit}
@@ -565,10 +567,10 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
<div>
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-fg-muted" />
{t("pricing.title")}
{heading ?? t("pricing.title")}
<PricingInfoTooltip vendor={vendor} />
</h3>
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
{!heading && <p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>}
</div>
<div className="flex items-center gap-2">
{hidden > 0 && (
@@ -610,159 +612,161 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
{actionBanner(["reset-pricing"])}
<div className="card overflow-x-auto mt-4">
<table className="w-full min-w-[1000px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")}
</th>
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")}
</th>
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.length === 0 && !adding && (
<tr>
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
{t(`pricing.empty.${vendor}`)}
</td>
{rows.length === 0 && !adding ? (
<p className="card mt-4 px-4 py-6 text-center text-xs text-fg-muted">
{t(`pricing.empty.${vendor}`)}
</p>
) : (
<div className="card overflow-x-auto mt-4">
<table className="w-full">
<thead>
<tr className="border-b border-border text-left">
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")}
</th>
<th className="w-20 px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")}
</th>
</tr>
)}
{rows.map((rule) =>
editingPattern === rule.model_pattern ? (
<Fragment key={rule.model_pattern}>
</thead>
<tbody className="divide-y divide-border">
{rows.map((rule) =>
editingPattern === rule.model_pattern ? (
<Fragment key={rule.model_pattern}>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</Fragment>
) : (
<tr
key={rule.model_pattern}
className="hover:bg-surface-4 transition-colors group"
>
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
until: rule.intro_until,
})}
</div>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
)}
{adding && (
<>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</Fragment>
) : (
<tr key={rule.model_pattern} className="hover:bg-surface-4 transition-colors group">
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
until: rule.intro_until,
})}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
)}
{adding && (
<>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</>
)}
</tbody>
</table>
</div>
</>
)}
</tbody>
</table>
</div>
)}
{lastUpdated && (
<p className="text-xs text-fg-muted mt-3">