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();
});
});