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
+2 -2
View File
File diff suppressed because one or more lines are too long
@@ -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">
+1 -1
View File
@@ -28,7 +28,7 @@
"cancel": "Cancel",
"create": "Create rule",
"saving": "Saving…",
"empty": "No alert rules yet. Add one to start monitoring for stuck agents, error bursts, idle sessions, or token spikes.",
"empty": "No alert rules yet",
"enabled": "Enabled",
"disabled": "Disabled",
"enable": "Enable rule",
+8 -1
View File
@@ -357,7 +357,14 @@
"home.worktreeCount_other": "{{count}} worktrees",
"home.placeholder": "How can I help you today?",
"home.start": "Start",
"home.worktree": "Worktree",
"home.permissionAuto": "Auto",
"home.permissionAcceptEdits": "Accept edits",
"home.permissionDefault": "Ask first",
"home.permissionPlan": "Plan",
"home.permissionBypass": "Bypass permissions",
"home.local": "Local",
"home.localHint": "Work in the workspace folder itself",
"home.worktree": "New worktree",
"home.worktreeHint": "Run this session in its own git worktree — <repo>/.claude/worktrees/, a branch of its own, made and cleaned up by Claude Code",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.recents": "Recents",
+6
View File
@@ -72,6 +72,12 @@
"cwdPlaceholder": "Type to search or paste an absolute path…",
"effort": "Thinking effort",
"model": "Model",
"moreModels": "More models",
"effortDefault": "Default",
"effortFaster": "Faster",
"effortSmarter": "Smarter",
"effortReset": "Back to the model's default",
"effortDefaultHint": "The model decides — no --effort flag",
"modelCustom": "Custom…",
"modelCustomPlaceholder": "model id (e.g. claude-opus-5)",
"modelInheritLabel": "Inherit from settings",
+9 -4
View File
@@ -11,6 +11,11 @@
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
},
"pricing": {
"vendor": {
"claude": "Anthropic (Claude)",
"openai": "OpenAI (Codex)",
"google": "Google (Gemini)"
},
"title": "Model Pricing",
"description": "Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.",
"introNote": "Intro ${{input}}/${{output}} through {{until}}",
@@ -475,7 +480,7 @@
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a workspace.",
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
@@ -560,7 +565,7 @@
},
"gitCreds": {
"title": "Git credentials",
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
"add": "Add login",
"replace": "Replace token",
"remove": "Delete",
@@ -630,7 +635,7 @@
"usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Workspaces:",
"defaultModel": "Default model",
"defaultModelHint": "The model a new run in this tool's workspaces starts on. The run form can still pick another one.",
"defaultModelHint": "The model a new session on this tool starts on. The workspace prompt box can still pick another one.",
"saveDefaultModel": "Save default model",
"config": "Config files",
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
@@ -682,7 +687,7 @@
},
"pricing": {
"title": "Pricing",
"description": "Model prices for every vendor, used to estimate cost."
"description": "Model prices for every vendor, used to estimate cost. Rates per million tokens (USD); % is a wildcard in a pattern; a model's introductory rate is shown in violet."
},
"gitCredentials": {
"title": "Git credentials",
+1 -1
View File
@@ -28,7 +28,7 @@
"cancel": "Hủy",
"create": "Tạo quy tắc",
"saving": "Đang lưu…",
"empty": "Chưa có quy tắc cảnh báo nào. Thêm một quy tắc để bắt đầu theo dõi agent bị kẹt, lỗi dồn dập, phiên nhàn rỗi hoặc token tăng vọt.",
"empty": "Chưa có quy tắc cảnh báo nào",
"enabled": "Đang bật",
"disabled": "Đang tắt",
"enable": "Bật quy tắc",
+8 -1
View File
@@ -357,7 +357,14 @@
"home.worktreeCount_other": "{{count}} worktree",
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
"home.start": "Bắt đầu",
"home.worktree": "Worktree",
"home.permissionAuto": "Tự động",
"home.permissionAcceptEdits": "Tự nhận sửa file",
"home.permissionDefault": "Hỏi trước",
"home.permissionPlan": "Lập kế hoạch",
"home.permissionBypass": "Bỏ qua quyền",
"home.local": "Thư mục chính",
"home.localHint": "Làm việc ngay trong thư mục của workspace",
"home.worktree": "Worktree mới",
"home.worktreeHint": "Chạy session này trong worktree git riêng — <repo>/.claude/worktrees/, branch riêng, do Claude Code tạo và dọn",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.recents": "Gần đây",
+6
View File
@@ -71,6 +71,12 @@
"cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…",
"effort": "Mức độ suy nghĩ",
"model": "Model",
"moreModels": "Thêm model",
"effortDefault": "Mặc định",
"effortFaster": "Nhanh hơn",
"effortSmarter": "Kỹ hơn",
"effortReset": "Về mặc định của model",
"effortDefaultHint": "Model tự quyết — không truyền --effort",
"modelCustom": "Tuỳ chỉnh…",
"modelCustomPlaceholder": "model id (vd: claude-opus-5)",
"modelInheritLabel": "Kế thừa từ settings",
+9 -4
View File
@@ -11,6 +11,11 @@
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)"
},
"pricing": {
"vendor": {
"claude": "Anthropic (Claude)",
"openai": "OpenAI (Codex)",
"google": "Google (Gemini)"
},
"title": "Định giá mô hình",
"description": "Đơn giá theo một triệu token (USD). Dùng % làm ký tự đại diện trong mẫu. Một số mô hình có mức giá giới thiệu có thời hạn (hiển thị màu tím); usage vào hoặc trước ngày kết thúc tính theo mức đó, sau đó tính theo mức chuẩn.",
"introNote": "Ưu đãi ${{input}}/${{output}} đến {{until}}",
@@ -475,7 +480,7 @@
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo workspace.",
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
@@ -560,7 +565,7 @@
},
"gitCreds": {
"title": "Git credentials",
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
"description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
"add": "Thêm tài khoản",
"replace": "Thay token",
"remove": "Xoá",
@@ -630,7 +635,7 @@
"usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Workspace:",
"defaultModel": "Model mặc định",
"defaultModelHint": "Model mà một lần chạy mới trong workspace của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"defaultModelHint": "Model mà một session mới trên công cụ này dùng. Ô nhập prompt của workspace vẫn đổi được.",
"saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hình",
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
@@ -682,7 +687,7 @@
},
"pricing": {
"title": "Định giá",
"description": "Giá mô hình của mọi nhà cung cấp, dùng để ước tính chi phí."
"description": "Giá mô hình của mọi nhà cung cấp, dùng để ước tính chi phí. Đơn giá theo một triệu token (USD); % là ký tự đại diện trong mẫu; giá giới thiệu hiển thị màu tím."
},
"gitCredentials": {
"title": "Git credentials",
+3 -8
View File
@@ -24,7 +24,7 @@
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Router as RouterIcon, Lock } from "lucide-react";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
@@ -181,13 +181,8 @@ export function AiRouterConfig() {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<RouterIcon className="h-4 w-4 text-fg-muted" />
{t("aiRouter.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
</div>
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("aiRouter.description")}</p>
<button
type="button"
onClick={startAdd}
+2 -2
View File
@@ -47,14 +47,14 @@ function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
<div className="space-y-2">
<p className="text-xs text-fg-muted">{t("toolTab.defaultModelHint")}</p>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[16rem] flex-1">
<div className="w-72 max-w-full">
<ModelPicker value={value} onChange={setValue} tool={toolId} />
</div>
<button
type="button"
onClick={save}
disabled={value === saved}
className="btn-ghost text-xs disabled:opacity-50"
className="btn-primary text-xs disabled:opacity-50"
>
{t("toolTab.saveDefaultModel")}
</button>
+3 -8
View File
@@ -10,7 +10,7 @@
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api";
const INPUT =
@@ -69,13 +69,8 @@ export function GitCredentialsConfig() {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<KeyRound className="h-4 w-4 text-fg-muted" />
{t("gitCreds.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
</div>
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
<button
type="button"
onClick={() => setForm({ url: "", username: "", token: "" })}
+50 -50
View File
@@ -392,22 +392,6 @@ export function Settings({
return (
<div className="animate-fade-in space-y-8">
{section === "general" && (
<div className="flex items-center justify-end gap-2">
<a
href={api.settings.exportData()}
download
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
>
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
)}
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
@@ -433,35 +417,6 @@ export function Settings({
/>
</SettingsSection>
{/* Cost summary card */}
<div className="card p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-status-success" />
</div>
<div>
<p className="text-sm text-fg-muted">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-fg-primary">
<Tip
raw={
totalCost !== null
? `$${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: undefined
}
>
{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}
</Tip>
</p>
</div>
</div>
<div className="text-right text-xs text-fg-muted">
<p>{t("acrossSessions")}</p>
<p>{t("basedOnUsage")}</p>
</div>
</div>
</div>
{/* ─── LANES ─── */}
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
@@ -752,13 +707,58 @@ export function Settings({
<>
{/* ─── DATA MANAGEMENT ─── */}
<section id="data" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-fg-muted" />
{t("data.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("data.description")}</p>
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-fg-muted" />
{t("data.title")}
</h3>
<p className="text-xs text-fg-muted">{t("data.description")}</p>
</div>
<div className="flex items-center gap-2">
<a
href={api.settings.exportData()}
download
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
>
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
</div>
<div className="space-y-4">
{/* Cost summary card */}
<div className="card p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-status-success" />
</div>
<div>
<p className="text-sm text-fg-muted">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-fg-primary">
<Tip
raw={
totalCost !== null
? `$${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: undefined
}
>
{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}
</Tip>
</p>
</div>
</div>
<div className="text-right text-xs text-fg-muted">
<p>{t("acrossSessions")}</p>
<p>{t("basedOnUsage")}</p>
</div>
</div>
</div>
<div className="card p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold flex-shrink-0">
+17 -12
View File
@@ -52,8 +52,6 @@ interface Section {
title: string;
description: string;
Icon: ComponentType<{ className?: string }>;
/** Full content width (config editors and tables), else `max-w-3xl`. */
wide?: boolean;
render: () => ReactNode;
}
@@ -119,7 +117,6 @@ export function SettingsHub() {
id: "agents",
group: "agents",
Icon: Bot,
wide: true,
...s("agents"),
render: () => <AiToolsSettings />,
},
@@ -129,7 +126,6 @@ export function SettingsHub() {
id: "agents/claude-code",
group: "agents" as const,
Icon: Boxes,
wide: true,
...s("claudeCode"),
render: () => <CcConfig />,
},
@@ -141,7 +137,6 @@ export function SettingsHub() {
id: "agents/codex",
group: "agents" as const,
Icon: SquareTerminal,
wide: true,
...s("codex"),
render: () => <CodexConfig />,
},
@@ -157,14 +152,12 @@ export function SettingsHub() {
Icon: x.id === "antigravity" ? Orbit : Puzzle,
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
wide: true,
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
})),
{
id: "ai-router",
group: "routing",
Icon: Router,
wide: true,
...s("aiRouter"),
render: () => <AiRouterConfig />,
},
@@ -172,13 +165,25 @@ export function SettingsHub() {
id: "pricing",
group: "routing",
Icon: Coins,
wide: true,
...s("pricing"),
render: () => (
<div className="space-y-6">
<PricingSettings vendor="claude" allowReset id="pricing-claude" />
<PricingSettings vendor="openai" id="pricing-openai" />
<PricingSettings vendor="google" id="pricing-google" />
<PricingSettings
vendor="claude"
allowReset
id="pricing-claude"
heading={t("pricing.vendor.claude")}
/>
<PricingSettings
vendor="openai"
id="pricing-openai"
heading={t("pricing.vendor.openai")}
/>
<PricingSettings
vendor="google"
id="pricing-google"
heading={t("pricing.vendor.google")}
/>
</div>
),
},
@@ -304,7 +309,7 @@ export function SettingsHub() {
})}
</nav>
<div className={`min-w-0 flex-1 ${active.wide ? "" : "max-w-3xl"}`}>
<div className="min-w-0 flex-1">
<select
aria-label={t("settingsNav.label")}
value={active.id}
@@ -642,10 +642,10 @@ exports[`screen snapshots > Activity feed 1`] = `
</div>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
class="flex flex-col items-center justify-center text-center py-20"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
class="rounded-2xl bg-surface-4 flex items-center justify-center w-14 h-14 mb-5"
>
<svg
class="lucide lucide-activity w-6 h-6 text-fg-muted"
@@ -2787,82 +2787,11 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
</div>
</div>
<div
class="card overflow-x-auto mt-4"
<p
class="card mt-4 px-4 py-6 text-center text-xs text-fg-muted"
>
<table
class="w-full min-w-[1000px]"
>
<thead>
<tr
class="border-b border-border text-left"
>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Pattern
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Model
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Output
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Read
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (5m)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (1h)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Output
</th>
<th
class="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody
class="divide-y divide-border"
>
<tr>
<td
class="px-4 py-6 text-center text-xs text-fg-muted"
colspan="10"
>
No Claude model prices. Reset Defaults restores them.
</td>
</tr>
</tbody>
</table>
</div>
No Claude model prices. Reset Defaults restores them.
</p>
</section>
</div>
</div>
@@ -6833,10 +6762,10 @@ exports[`screen snapshots > Sessions 1`] = `
</div>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
class="flex flex-col items-center justify-center text-center py-20"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
class="rounded-2xl bg-surface-4 flex items-center justify-center w-14 h-14 mb-5"
>
<svg
class="lucide lucide-folder-open w-6 h-6 text-fg-muted"
@@ -6875,73 +6804,6 @@ exports[`screen snapshots > Settings 1`] = `
<div
class="animate-fade-in space-y-8"
>
<div
class="flex items-center justify-end gap-2"
>
<a
class="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
download=""
href="/api/settings/export"
>
<svg
class="lucide lucide-file-down w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"
/>
<path
d="M14 2v4a2 2 0 0 0 2 2h4"
/>
<path
d="M12 18v-6"
/>
<path
d="m9 15 3 3 3-3"
/>
</svg>
Export Data
</a>
<button
class="btn-ghost"
>
<svg
class="lucide lucide-refresh-cw w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
<section
class="card p-5"
>
@@ -6999,70 +6861,6 @@ exports[`screen snapshots > Settings 1`] = `
</div>
</div>
</section>
<div
class="card p-6"
>
<div
class="flex items-center justify-between flex-wrap gap-4"
>
<div
class="flex items-center gap-4"
>
<div
class="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center"
>
<svg
class="lucide lucide-dollar-sign w-6 h-6 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
</div>
<div>
<p
class="text-sm text-fg-muted"
>
Total Estimated Cost
</p>
<p
class="text-2xl font-semibold text-fg-primary"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</p>
</div>
</div>
<div
class="text-right text-xs text-fg-muted"
>
<p>
Across all tracked sessions
</p>
<p>
Based on per-model token usage
</p>
</div>
</div>
</div>
<section
class="scroll-mt-24"
id="lanes"
@@ -8125,44 +7923,181 @@ exports[`screen snapshots > Settings 1`] = `
class="scroll-mt-24"
id="data"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
<div
class="mb-4 flex flex-wrap items-start justify-between gap-2"
>
<svg
class="lucide lucide-database w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<div>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-database w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 5V19A9 3 0 0 0 21 19V5"
/>
<path
d="M3 12A9 3 0 0 0 21 12"
/>
</svg>
Data Management
</h3>
<p
class="text-xs text-fg-muted"
>
Database info, import/export, and cleanup.
</p>
</div>
<div
class="flex items-center gap-2"
>
<ellipse
cx="12"
cy="5"
rx="9"
ry="3"
/>
<path
d="M3 5V19A9 3 0 0 0 21 19V5"
/>
<path
d="M3 12A9 3 0 0 0 21 12"
/>
</svg>
Data Management
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
Database info, import/export, and cleanup.
</p>
<a
class="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
download=""
href="/api/settings/export"
>
<svg
class="lucide lucide-file-down w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"
/>
<path
d="M14 2v4a2 2 0 0 0 2 2h4"
/>
<path
d="M12 18v-6"
/>
<path
d="m9 15 3 3 3-3"
/>
</svg>
Export Data
</a>
<button
class="btn-ghost"
>
<svg
class="lucide lucide-refresh-cw w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Refresh
</button>
</div>
</div>
<div
class="space-y-4"
>
<div
class="card p-6"
>
<div
class="flex items-center justify-between flex-wrap gap-4"
>
<div
class="flex items-center gap-4"
>
<div
class="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center"
>
<svg
class="lucide lucide-dollar-sign w-6 h-6 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
</div>
<div>
<p
class="text-sm text-fg-muted"
>
Total Estimated Cost
</p>
<p
class="text-2xl font-semibold text-fg-primary"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</p>
</div>
</div>
<div
class="text-right text-xs text-fg-muted"
>
<p>
Across all tracked sessions
</p>
<p>
Based on per-model token usage
</p>
</div>
</div>
</div>
<div
class="card p-5 space-y-4"
>
@@ -9123,10 +9058,10 @@ exports[`screen snapshots > Settings — notifications 1`] = `
</button>
</div>
<div
class="flex flex-col items-center justify-center py-20 text-center"
class="flex flex-col items-center justify-center text-center py-8"
>
<div
class="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5"
class="rounded-2xl bg-surface-4 flex items-center justify-center w-10 h-10 mb-3"
>
<svg
class="lucide lucide-list-checks w-6 h-6 text-fg-muted"
@@ -9160,10 +9095,10 @@ exports[`screen snapshots > Settings — notifications 1`] = `
<h3
class="text-base font-medium text-fg-secondary mb-2"
>
No alert rules yet. Add one to start monitoring for stuck agents, error bursts, idle sessions, or token spikes.
No alert rules yet
</h3>
<p
class="text-sm text-fg-muted max-w-md mb-6"
class="text-sm text-fg-muted max-w-md "
>
Add a rule to start alerting on errors, inactivity, stuck agents, or token thresholds.
</p>
+2 -2
View File
@@ -93,7 +93,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
## Workspace tool: Claude or Codex
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select, first of its fields (AI → AI Router → Model → Permission → Effort), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Router, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
@@ -394,7 +394,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the ONE place a session starts (a pane has no run form) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.