feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+51 -13
View File
@@ -43,7 +43,7 @@ import {
RotateCcw,
Lock,
} from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api";
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
@@ -409,6 +409,8 @@ export function RunSetup(props: RunSetupProps) {
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
@@ -441,8 +443,8 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
{/* A catalog tool takes no model, permission or effort flag. */}
{!catalogTool && (
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
{hasModel && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
@@ -532,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s, bypass);
if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
@@ -559,7 +562,9 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props, undefined, canBypass && bypass)}
onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
slash={props.tool === "claude"}
@@ -615,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
) : null}
</div>
<button
onClick={() => handleStart(props, undefined, canBypass && bypass)}
onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
@@ -644,7 +651,13 @@ export function RunSetup(props: RunSetupProps) {
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean) {
function handleStart(
props: RunSetupProps,
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null
) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
// Any catalog tool: none of Claude's or Codex's own flags apply.
@@ -654,7 +667,12 @@ function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean)
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: other ? undefined : props.model || undefined,
// A resume keeps the session's own model: the tool's default the form
// starts on is not a pick, so only a model the user changed is sent.
model:
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
? undefined
: props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
@@ -1029,7 +1047,7 @@ function SessionPicker({
// the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__";
function ModelPicker({
export function ModelPicker({
value,
onChange,
tool,
@@ -1039,11 +1057,31 @@ function ModelPicker({
tool: LaneTool;
}) {
const { t } = useTranslation("run");
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES;
// "Custom" is selected when the value isn't one of our curated IDs.
// 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.
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
useEffect(() => {
let live = true;
setFetched(null);
Promise.resolve()
.then(() => api.aiTools.models(tool))
.then((r) => live && setFetched(r.models))
.catch(() => live && setFetched([]));
return () => {
live = false;
};
}, [tool]);
const choices = useMemo<ModelChoice[]>(() => {
const inherit = { id: "", label: "" };
if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fetched, tool]);
// "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 = value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(isCustom);
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of