Files
AgentHub/client/src/components/run/RunSetup.tsx
T
nntrivi2001 3f50e162e6 feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
2026-09-23 13:23:20 +07:00

791 lines
31 KiB
TypeScript

/**
* @file RunSetup.tsx
* @description The pre-run setup panel: everything the user picks before a run
* exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so
* the Run page and the Workspace page can both mount the same panel.
*
* What lives here:
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
* model / effort fields, plus per-tool advanced fields (permission-mode for
* Claude; sandbox and approval for Codex), plus the concurrency hint and
* the Start button. Its disabled state is driven by the `binaryFound` prop
* for the lane's tool, so a missing binary is a surfaced state here rather
* than a probe of its own. Picking a session to resume starts that run
* immediately — a resume carries its own history, so there is nothing to
* type first; the prompt stays optional for resumes and required for fresh
* runs. Codex lanes show a "resume --last" button instead of the session
* picker (no sessions in the dashboard database to list).
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
* `ModelPicker`, and the small `Field` layout helper.
*
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
* lifecycle — the page owns `api.run.start` and hands the result back through
* `onStart`. `SessionPicker` keeps the one API call it already owned (listing
* past sessions to resume) and `PromptEditor` keeps its `@`-file lookup.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Play,
RefreshCw,
AlertCircle,
ChevronDown,
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
RotateCcw,
Lock,
} from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
RunListResponse,
EffortLevel,
PermissionMode,
RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
};
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
);
}
// ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps {
laneId: number;
prompt: string;
onPromptChange: (s: string) => void;
cwd: string;
onCwdChange: (s: string) => void;
cwdSuggestions: CwdSuggestion[];
model: string;
onModelChange: (s: string) => void;
permissionMode: PermissionMode;
onPermissionModeChange: (m: PermissionMode) => void;
effort: EffortLevel;
onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean;
busy: boolean;
onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null;
resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void;
/** The selected lane's cwd - narrows the resume picker to that lane's own
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */
tool: LaneTool;
sandbox: CodexSandbox;
onSandboxChange: (s: CodexSandbox) => void;
approval: CodexApproval;
onApprovalChange: (a: CodexApproval) => void;
/** Codex lanes resume with `--last`: the sessions table holds Claude
* sessions only, so there is nothing for the picker to list. */
onResumeLast: () => void;
}
export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run");
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
// 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.
useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true);
}, [isResume, resumePicked]);
return (
<div className="rounded-xl border border-border bg-surface-1">
{/* Run settings and the fresh-vs-resume source picker share one row:
settings left, source right. They used to be two rows with a divider
between them, which spent a band of height on four labelled selects
that are each one line tall. The resume picker below is the only part
that still gets a row of its own, and only while resume is chosen. */}
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("fields.cwd")}>
{isResume && props.resumeSession ? (
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span>
</div>
) : (
<div title={t("fields.cwdHint")}>
<CwdAutocomplete
value={props.cwd}
onChange={props.onCwdChange}
suggestions={props.cwdSuggestions}
/>
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
value={props.sandbox}
onChange={props.onSandboxChange}
options={[
{ value: "read-only", label: t("fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("fields.approval")}>
<Select<CodexApproval>
value={props.approval}
onChange={props.onApprovalChange}
options={[
{ value: "on-request", label: t("fields.approvalOnRequest") },
{ value: "never", label: t("fields.approvalNever") },
]}
/>
</Field>
</>
) : (
<Field label={t("fields.permissionMode")}>
<Select<PermissionMode>
value={props.permissionMode}
onChange={props.onPermissionModeChange}
options={[
{ value: "auto", label: t("fields.permissionAuto") },
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
{ value: "default", label: t("fields.permissionDefault") },
{ value: "plan", label: t("fields.permissionPlan") },
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
]}
/>
</Field>
)}
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
</div>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
</div>
{resumePicked && (
<div className="border-b border-border px-4 py-2 text-[11.5px]">
<div className="min-w-0">
{props.tool === "claude" ? (
<SessionPicker
selected={props.resumeSession}
onSelect={(s) => {
props.onResumeSessionChange(s);
// 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);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
cwd={props.laneCwd || props.cwd.trim() || undefined}
/>
) : (
<button
onClick={() => props.onResumeLast()}
disabled={props.busy}
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("resume.lastOption")}
</button>
)}
</div>
</div>
)}
{/* Prompt */}
<div className="px-4 py-3 border-b border-border">
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("fields.prompt")}
</label>
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
rows={5}
slashCommands={props.slashCommands ?? []}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
{t("hint.shortcut")} · / for slash commands · @ for file references
</div>
</div>
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.sandboxWarning")}</span>
</div>
)}
{props.tool === "claude" && props.permissionMode === "bypassPermissions" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.permissionWarning")}</span>
</div>
)}
{/* Footer: contextual run-state hint + run button */}
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 text-[11px] min-w-0">
{atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: 0 })}
</span>
) : null}
</div>
<button
onClick={() => handleStart(props)}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
(!props.resumeSession && !props.prompt.trim()) ||
props.busy ||
atCap ||
(resumePicked && !props.resumeSession) ||
// Resume locks cwd to the original session, so allow it then;
// otherwise require a non-empty cwd so we never spawn at an
// invisible default.
(!props.resumeSession && !props.cwd.trim())
}
className="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.busy ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{props.busy ? t("actions.starting") : t("actions.start")}
</button>
</div>
</div>
);
}
/** `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) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
props.onStart({
laneId: props.laneId,
// 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: props.model || undefined,
permissionMode: codex ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
});
}
/** One segment of a two-way inline choice. The explanation rides on `title`
* instead of a hint line, which is what keeps the row to one line. */
function Seg({
active,
label,
title,
onClick,
}: {
active: boolean;
label: string;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-pressed={active}
className={`rounded px-2 py-0.5 font-medium transition-colors ${
active ? "bg-accent/20 text-accent" : "text-fg-secondary hover:text-fg-primary"
}`}
>
{label}
</button>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{label}
</label>
{children}
</div>
);
}
// ── CWD autocomplete ──────────────────────────────────────────────────
export function CwdAutocomplete({
value,
onChange,
suggestions,
inputId,
}: {
value: string;
onChange: (s: string) => void;
suggestions: CwdSuggestion[];
/** Sets the input's `id` so an external `<label htmlFor>` can target it. */
inputId?: string;
}) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
const filtered = useMemo(() => {
const q = value.toLowerCase().trim();
const out = suggestions.filter(
(s) => !q || s.path.toLowerCase().includes(q) || s.label.toLowerCase().includes(q)
);
return out;
}, [value, suggestions]);
// Group suggestions by kind preserving fixed order — home first, matching
// the neutral default the page pre-fills (issue #202).
const groups = useMemo(() => {
// "repo" (source repos lanes were cut from) sits above the recents: on the
// Add-lane form it is the group the field is actually asking for, and on
// the Run form it is a short, deliberate list rather than history.
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
return order
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
.filter((g) => g.items.length > 0);
}, [filtered]);
// Flat index for keyboard navigation
const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
// Keep `active` clamped within bounds
useEffect(() => {
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
}, [flat.length, active]);
const choose = (s: CwdSuggestion) => {
onChange(s.path);
setOpen(false);
inputRef.current?.blur();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
setOpen(true);
e.preventDefault();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(flat.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
if (open && flat[active]) {
e.preventDefault();
choose(flat[active]);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={containerRef} className="relative">
<div className="relative">
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
<input
ref={inputRef}
id={inputId}
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder={t("fields.cwdPlaceholder")}
autoComplete="off"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
</div>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
{groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : (
groups.map((g) => (
<div key={g.kind}>
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted flex items-center gap-1.5">
{g.kind === "dashboard" ? (
<FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? (
<Home className="w-3 h-3" />
) : g.kind === "repo" ? (
<GitBranch className="w-3 h-3" />
) : (
<HistoryIcon className="w-3 h-3" />
)}
{t(`fields.cwdGroups.${g.kind}`)}
</div>
{g.items.map((s) => {
const idx = flat.indexOf(s);
const isActive = idx === active;
return (
<button
key={s.path}
type="button"
onMouseDown={(e) => e.preventDefault() /* keep input focused */}
onClick={() => choose(s)}
onMouseEnter={() => setActive(idx)}
className={`w-full text-left px-3 py-1.5 transition-colors ${
isActive ? "bg-accent/15" : "hover:bg-surface-3"
}`}
>
<div className="text-[11px] text-fg-secondary truncate">{s.label}</div>
<div className="font-mono text-[10px] text-fg-muted truncate">{s.path}</div>
</button>
);
})}
</div>
))
)}
</div>
)}
</div>
);
}
// ── Model picker ──────────────────────────────────────────────────────
// ── Session picker (for resume) ───────────────────────────────────────
function SessionPicker({
selected,
onSelect,
cwd,
}: {
selected: Session | null;
onSelect: (s: Session | null) => void;
/** Restricts the list to sessions under this exact working directory - the
* lane a resume is started from should only offer that lane's own history. */
cwd?: string;
}) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<Session[] | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// Lazily load sessions when the picker opens, and again whenever the lane
// (and so its cwd filter) changes - otherwise switching lanes would leave
// the picker showing the PREVIOUS lane's sessions until closed and reopened.
const fetchedForCwd = useRef<string | undefined>(undefined);
useEffect(() => {
if (!open) return;
if (sessions !== null && fetchedForCwd.current === cwd) return;
fetchedForCwd.current = cwd;
api.sessions
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
.then((r) => setSessions(r.sessions))
.catch(() => setSessions([]));
}, [open, cwd, sessions]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
const filtered = useMemo(() => {
if (!sessions) return [];
const q = query.toLowerCase().trim();
if (!q) return sessions;
return sessions.filter(
(s) =>
s.id.toLowerCase().includes(q) ||
(s.cwd || "").toLowerCase().includes(q) ||
(s.status || "").toLowerCase().includes(q)
);
}, [sessions, query]);
if (selected) {
return (
<div className="mt-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 flex items-start gap-2">
<RotateCcw className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
{t("resume.selectedBadge")}
</span>
<span className="font-mono text-[11px] text-fg-secondary truncate">{selected.id}</span>
</div>
<div className="font-mono text-[10px] text-fg-muted truncate mt-0.5">{selected.cwd}</div>
</div>
<button
onClick={() => onSelect(null)}
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1 flex-shrink-0"
>
<X className="w-3 h-3" />
{t("resume.clear")}
</button>
</div>
);
}
return (
<div ref={containerRef} className="relative mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-fg-secondary inline-flex items-center gap-2"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("resume.pickSession")}
<ChevronDown className="w-3 h-3 opacity-70 ml-auto" />
</button>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("resume.search")}
className="bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none w-full"
/>
</div>
<div className="max-h-72 overflow-auto py-1">
{sessions === null ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">…</div>
) : filtered.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("resume.noSessions")}</div>
) : (
filtered.map((s) => (
<button
key={s.id}
onClick={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full text-left px-3 py-2 hover:bg-surface-3 transition-colors"
>
<div className="flex items-center gap-2 mb-0.5">
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
}`}
>
{s.status}
</span>
{s.name?.trim() && (
<span className="text-[11px] text-fg-secondary truncate">
{s.name.trim()}
</span>
)}
<span className="font-mono text-[11px] text-fg-secondary truncate flex-shrink-0">
{s.id.slice(0, 12)}…
</span>
<span className="text-[10px] text-fg-muted ml-auto flex-shrink-0">
{new Date(s.started_at).toLocaleString()}
</span>
</div>
<div className="font-mono text-[10px] text-fg-muted truncate">{s.cwd}</div>
</button>
))
)}
</div>
</div>
)}
</div>
);
}
// The custom Select dropdown now lives in ../components/Select (shared with the
// webhook settings form). Imported at the top of this file.
// Sentinel option value for "Custom model…". Empty string is already taken by
// the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__";
function ModelPicker({
value,
onChange,
tool,
}: {
value: string;
onChange: (s: string) => void;
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.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom = value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(isCustom);
// Reuse the shared Select so the Model dropdown renders identically to the
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
// a browser-native <select>.
const options = useMemo(
() => [
...choices.map((c) => ({
value: c.id === "" ? "" : c.id,
label: c.id === "" ? t("fields.modelInheritLabel") : c.label,
hint: c.hint,
})),
{ value: MODEL_CUSTOM, label: t("fields.modelCustom") },
],
[t, choices]
);
const onSelect = (v: string) => {
if (v === MODEL_CUSTOM) {
setShowCustom(true);
return;
}
setShowCustom(false);
onChange(v);
};
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
return (
<div className="space-y-1.5">
<Select<string> value={selectValue} onChange={onSelect} options={options} />
{(showCustom || isCustom) && (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("fields.modelCustomPlaceholder")}
autoComplete="off"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
)}
</div>
);
}