feat(run): per-tool run setup fields and binary probe

This commit is contained in:
2026-08-27 10:51:41 +07:00
parent 3891f64f04
commit abf3181843
7 changed files with 186 additions and 46 deletions
+18 -4
View File
@@ -28,8 +28,10 @@ import type {
RunHandle,
RunListResponse,
RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session, Lane } from "../../lib/types";
import type { Session, Lane, LaneTool } from "../../lib/types";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
@@ -40,7 +42,7 @@ export interface LaneConsolePaneProps {
showLaneSelector: boolean;
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: { found: boolean; path: string | null } | null;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
@@ -79,8 +81,11 @@ export function LaneConsolePane({
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -239,6 +244,9 @@ export function LaneConsolePane({
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
@@ -426,7 +434,7 @@ export function LaneConsolePane({
/>
</header>
{binaryStatus && !binaryStatus.found && (
{binaryStatus && !binaryStatus[laneTool]?.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
@@ -454,7 +462,7 @@ export function LaneConsolePane({
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
binaryFound={binaryStatus?.[laneTool]?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
@@ -463,6 +471,12 @@ export function LaneConsolePane({
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
tool={laneTool}
sandbox={sandbox}
onSandboxChange={setSandbox}
approval={approval}
onApprovalChange={setApproval}
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
+87 -29
View File
@@ -47,8 +47,10 @@ import type {
EffortLevel,
PermissionMode,
RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session } from "../../lib/types";
import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
@@ -117,6 +119,15 @@ interface RunSetupProps {
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) {
@@ -154,19 +165,29 @@ export function RunSetup(props: RunSetupProps) {
</div>
{resumePicked && (
<div className="min-w-0 flex-1">
<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}
/>
{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>
@@ -211,18 +232,44 @@ export function RunSetup(props: RunSetupProps) {
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} />
</Field>
<Field label={t("fields.permissionMode")}>
<Select<PermissionMode>
value={props.permissionMode}
onChange={props.onPermissionModeChange}
options={[
{ 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 === "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: "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}
@@ -236,7 +283,14 @@ export function RunSetup(props: RunSetupProps) {
</Field>
</div>
{props.permissionMode === "bypassPermissions" && (
{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>
@@ -285,15 +339,19 @@ export function RunSetup(props: RunSetupProps) {
* 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: props.permissionMode || undefined,
permissionMode: codex ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: props.effort || undefined,
resumeSessionId: resume?.id || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
initialPrompt: props.prompt || undefined,
});
}
@@ -74,7 +74,10 @@ function baseProps() {
showLaneSelector: false,
onLaneIdChange: vi.fn(),
onLaneCreated: vi.fn(),
binaryStatus: { found: true, path: "/usr/local/bin/claude" },
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
},
cwdSuggestions: [],
activeRuns: { items: [] },
wsConnected: true,
@@ -12,6 +12,7 @@
import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { RunSetup } from "../RunSetup";
@@ -47,6 +48,9 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
onStart: vi.fn(),
onResumeSessionChange: vi.fn(),
onResumeFromHistory: vi.fn(),
onSandboxChange: vi.fn(),
onApprovalChange: vi.fn(),
onResumeLast: vi.fn(),
};
const utils = render(
<MemoryRouter>
@@ -64,6 +68,9 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
resumeSession={null}
slashCommands={[]}
runHistory={[]}
tool="claude"
sandbox="workspace-write"
approval="on-request"
{...spies}
{...overrides}
/>
@@ -273,6 +280,9 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
tool="claude"
sandbox="workspace-write"
approval="on-request"
onPromptChange={vi.fn()}
onCwdChange={vi.fn()}
onModelChange={vi.fn()}
@@ -281,6 +291,9 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
onStart={vi.fn()}
onResumeSessionChange={vi.fn()}
onResumeFromHistory={vi.fn()}
onSandboxChange={vi.fn()}
onApprovalChange={vi.fn()}
onResumeLast={vi.fn()}
/>
</MemoryRouter>
);
@@ -291,3 +304,29 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
);
});
});
describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" });
expect(screen.getByText(/sandbox/i)).toBeInTheDocument();
expect(screen.getByText(/approval/i)).toBeInTheDocument();
expect(screen.queryByText(/permission mode/i)).not.toBeInTheDocument();
});
it("keeps permission mode for a claude lane", () => {
renderSetup({ tool: "claude" });
expect(screen.getByText(/permission mode/i)).toBeInTheDocument();
expect(screen.queryByText(/sandbox/i)).not.toBeInTheDocument();
});
it("sends codex fields on start", async () => {
const onStart = vi.fn();
const user = userEvent.setup();
renderSetup({ tool: "codex", onStart, prompt: "go" });
await user.click(screen.getByText(i18n.t("run:actions.start")));
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ sandbox: "workspace-write", approval: "on-request" })
);
expect(onStart.mock.calls[0]?.[0]?.permissionMode).toBeUndefined();
});
});
+10 -1
View File
@@ -54,6 +54,9 @@
"you": "You"
},
"fields": {
"approval": "Approval",
"approvalNever": "Never",
"approvalOnRequest": "On request",
"cwd": "Working directory",
"cwdGroups": {
"dashboard": "Dashboard server",
@@ -75,7 +78,11 @@
"permissionPlan": "plan (read-only planning)",
"prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask Claude anything…"
"promptPlaceholderTerminal": "Ask Claude anything…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Full access (dangerous)",
"sandboxReadOnly": "Read only",
"sandboxWorkspaceWrite": "Workspace write"
},
"footer": {
"cost": "Cost",
@@ -87,6 +94,7 @@
"hint": {
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
},
"limitations": {
@@ -104,6 +112,7 @@
"freshHint": "Start a fresh Claude Code session.",
"freshOption": "New session",
"label": "Source session",
"lastOption": "Resume last session",
"noSessions": "No sessions found.",
"originalCwd": "Original cwd",
"pickSession": "Pick a session…",
+10 -1
View File
@@ -53,6 +53,9 @@
"you": "Bạn"
},
"fields": {
"approval": "Phê duyệt",
"approvalNever": "Không bao giờ",
"approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc",
"cwdGroups": {
"dashboard": "Dashboard server",
@@ -74,7 +77,11 @@
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…"
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
"sandboxReadOnly": "Chỉ đọc",
"sandboxWorkspaceWrite": "Ghi vào workspace"
},
"footer": {
"cost": "Chi phí",
@@ -86,6 +93,7 @@
"hint": {
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
},
"limitations": {
@@ -103,6 +111,7 @@
"freshHint": "Bắt đầu phiên Claude Code mới.",
"freshOption": "Phiên mới",
"label": "Phiên nguồn",
"lastOption": "Tiếp tục phiên trước",
"noSessions": "Không tìm thấy phiên.",
"originalCwd": "cwd gốc",
"pickSession": "Chọn phiên…",
+18 -10
View File
@@ -44,7 +44,15 @@ import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, LaneCounts, ProofFeature, Session, WSMessage } from "../lib/types";
import type {
Lane,
LaneFeature,
LaneCounts,
ProofFeature,
Session,
WSMessage,
LaneTool,
} from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap";
@@ -109,7 +117,7 @@ function ConsoleArea({
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
setPaneLaneId: (index: number, id: number) => void;
binaryStatus: { found: boolean; path: string | null } | null;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
externalSessions: Session[];
@@ -192,9 +200,10 @@ export function Workspace() {
// started in a terminal tab). They are not dashboard runs, so the run list
// would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>(
null
);
const [binaryStatus, setBinaryStatus] = useState<Record<
LaneTool,
{ found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
@@ -241,12 +250,11 @@ export function Workspace() {
}, []);
useEffect(() => {
api.run
.binary()
.then(setBinaryStatus)
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "claude missing" banner for an unrelated fault.
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "binary missing" banner for an unrelated fault.
.catch(() => undefined);
api.lanes
.pipelines()