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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user