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