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
+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");
});
});