9d096b61f7
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
carries the first prompt, `--continue` resumes; `agy` has no model,
effort or permission flag, and `--dangerously-skip-permissions` is never
emitted. No pipeline, no agents install, and no AI Router (native only,
else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
and the prompt box names the lane's CLI ("Ask Codex anything…"), via
LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
(healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
continues the previous thread.
1054 lines
40 KiB
TypeScript
1054 lines
40 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,
|
|
CcMdItem,
|
|
} from "../../lib/api";
|
|
import type { Session, LaneTool } from "../../lib/types";
|
|
import { LANE_TOOL_LABEL } from "../../lib/types";
|
|
import { Select } from "../Select";
|
|
|
|
export interface SlashCommand {
|
|
name: string;
|
|
source: "project" | "user" | "builtin";
|
|
description?: string;
|
|
}
|
|
|
|
// Commands the CLI answers itself. They are typed into the pane like any
|
|
// other text, so they do run — they are just not files anyone can list.
|
|
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
"help",
|
|
"clear",
|
|
"compact",
|
|
"config",
|
|
"model",
|
|
"memory",
|
|
"hooks",
|
|
"cost",
|
|
"agents",
|
|
"review",
|
|
"permissions",
|
|
"status",
|
|
"init",
|
|
"mcp",
|
|
"plugin",
|
|
"output-style",
|
|
"exit",
|
|
].map((name) => ({ name, source: "builtin" }));
|
|
|
|
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
|
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
|
project: "text-status-success border-status-success/40",
|
|
user: "text-accent border-accent/40",
|
|
builtin: "text-fg-muted border-border",
|
|
};
|
|
|
|
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
|
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
|
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
|
if (!q) return 1;
|
|
const n = name.toLowerCase();
|
|
if (n === q) return 1000;
|
|
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
|
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
|
const idx = n.indexOf(q);
|
|
if (idx >= 0) return 400 - Math.min(idx, 100);
|
|
let i = 0;
|
|
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
|
if (i === q.length) return 200;
|
|
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
|
return 0;
|
|
}
|
|
|
|
interface Trigger {
|
|
kind: "slash" | "file";
|
|
query: string;
|
|
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
|
start: number;
|
|
cursor: number;
|
|
}
|
|
|
|
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
|
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
|
function detectTrigger(value: string, cursor: number): Trigger | null {
|
|
let start = cursor;
|
|
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
|
const tok = value.slice(start, cursor);
|
|
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
|
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
|
return null;
|
|
}
|
|
|
|
interface PromptEditorProps {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
onSubmit: () => void;
|
|
placeholder: string;
|
|
rows?: number;
|
|
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
|
slash: boolean;
|
|
/** Where `@` looks for files and project commands/skills are read from. */
|
|
fileCwd: string;
|
|
}
|
|
|
|
function PromptEditor({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder,
|
|
rows = 5,
|
|
slash,
|
|
fileCwd,
|
|
}: PromptEditorProps) {
|
|
const { t } = useTranslation("run");
|
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
|
const [active, setActive] = useState(0);
|
|
const [files, setFiles] = useState<string[]>([]);
|
|
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
|
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
|
if (commandsCwd !== fileCwd) {
|
|
setCommandsCwd(fileCwd);
|
|
setCommands(null);
|
|
}
|
|
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
|
|
|
// Commands and skills are read the first time `/` is typed for this cwd —
|
|
// two directory walks nobody should pay for on every render of the form.
|
|
useEffect(() => {
|
|
if (kind !== "slash" || commands) return;
|
|
let live = true;
|
|
const toCmd = (c: CcMdItem): SlashCommand => ({
|
|
name: c.name,
|
|
source: c.scope,
|
|
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
|
});
|
|
Promise.all([
|
|
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
|
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
|
]).then(([c, sk]) => {
|
|
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [kind, commands, fileCwd]);
|
|
|
|
// Files: debounced, and only the newest query may land.
|
|
const query = trigger?.query ?? "";
|
|
useEffect(() => {
|
|
if (kind !== "file") return;
|
|
let live = true;
|
|
const tid = window.setTimeout(() => {
|
|
api.run
|
|
.files(fileCwd, query)
|
|
.then((r) => live && setFiles(r.items))
|
|
.catch(() => live && setFiles([]));
|
|
}, 120);
|
|
return () => {
|
|
live = false;
|
|
window.clearTimeout(tid);
|
|
};
|
|
}, [kind, query, fileCwd]);
|
|
|
|
const slashItems = useMemo(() => {
|
|
if (kind !== "slash" || !commands) return [];
|
|
const q = query.toLowerCase();
|
|
return commands
|
|
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
|
.filter((x) => x.score > 0)
|
|
.sort(
|
|
(a, b) =>
|
|
b.score - a.score ||
|
|
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
|
a.cmd.name.localeCompare(b.cmd.name)
|
|
)
|
|
.map((x) => x.cmd);
|
|
}, [kind, commands, query]);
|
|
const count = kind === "file" ? files.length : slashItems.length;
|
|
const current = Math.min(active, Math.max(0, count - 1));
|
|
|
|
const pick = (token: string) => {
|
|
if (!trigger) return;
|
|
const before = value.slice(0, trigger.start);
|
|
const after = value.slice(trigger.cursor);
|
|
const sep = after.startsWith(" ") ? "" : " ";
|
|
onChange(before + token + sep + after);
|
|
setTrigger(null);
|
|
setActive(0);
|
|
const pos = before.length + token.length + 1;
|
|
requestAnimationFrame(() => {
|
|
taRef.current?.focus();
|
|
taRef.current?.setSelectionRange(pos, pos);
|
|
});
|
|
};
|
|
const pickAt = (i: number) => {
|
|
if (kind === "file") {
|
|
const f = files[i];
|
|
if (f) pick(`@${f}`);
|
|
} else {
|
|
const c = slashItems[i];
|
|
if (c) pick(`/${c.name}`);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (kind && count > 0) {
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
|
return;
|
|
}
|
|
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
e.preventDefault();
|
|
pickAt(current);
|
|
return;
|
|
}
|
|
}
|
|
if (kind && e.key === "Escape") {
|
|
e.preventDefault();
|
|
setTrigger(null);
|
|
return;
|
|
}
|
|
// 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();
|
|
}
|
|
};
|
|
|
|
const retrack = (ta: HTMLTextAreaElement) => {
|
|
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
|
};
|
|
|
|
const rowClass = (i: number) =>
|
|
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
|
|
|
return (
|
|
<div className="relative">
|
|
<textarea
|
|
ref={taRef}
|
|
value={value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
retrack(e.target);
|
|
setActive(0);
|
|
}}
|
|
onSelect={(e) => retrack(e.currentTarget)}
|
|
onBlur={() => setTrigger(null)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
spellCheck={false}
|
|
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"
|
|
/>
|
|
{kind && (
|
|
<div
|
|
role="listbox"
|
|
data-testid="prompt-autocomplete"
|
|
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
|
>
|
|
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
|
</div>
|
|
{count === 0 ? (
|
|
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
|
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
|
</div>
|
|
) : kind === "slash" ? (
|
|
slashItems.map((c, i) => (
|
|
<button
|
|
key={`${c.source}:${c.name}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === current}
|
|
// Keep focus in the textarea, or its blur closes the list first.
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => pickAt(i)}
|
|
onMouseEnter={() => setActive(i)}
|
|
className={rowClass(i)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
|
<span
|
|
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
|
>
|
|
{c.source}
|
|
</span>
|
|
</div>
|
|
{c.description && (
|
|
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
|
)}
|
|
</button>
|
|
))
|
|
) : (
|
|
files.map((f, i) => (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === current}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => pickAt(i)}
|
|
onMouseEnter={() => setActive(i)}
|
|
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
|
>
|
|
{f}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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;
|
|
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>
|
|
{/* `agy` takes no model, permission or effort flag. */}
|
|
{props.tool !== "antigravity" && (
|
|
<Field label={t("fields.model")}>
|
|
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
|
</Field>
|
|
)}
|
|
{props.tool === "antigravity" ? null : 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>
|
|
)}
|
|
{props.tool !== "antigravity" && (
|
|
<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", { tool: LANE_TOOL_LABEL[props.tool] })}
|
|
rows={5}
|
|
slash={props.tool === "claude"}
|
|
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";
|
|
const agy = props.tool === "antigravity";
|
|
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: agy ? undefined : props.model || undefined,
|
|
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
|
|
sandbox: codex ? props.sandbox : undefined,
|
|
approval: codex ? props.approval : undefined,
|
|
effort: agy ? undefined : props.effort || undefined,
|
|
resumeSessionId: codex || agy ? undefined : resume?.id || 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-700 dark: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>
|
|
);
|
|
}
|