diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index f42cabd7..e8849700 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -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 | 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(null); const [runHistory, setRunHistory] = useState([]); + const [sandbox, setSandbox] = useState("workspace-write"); + const [approval, setApproval] = useState("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({ /> - {binaryStatus && !binaryStatus.found && ( + {binaryStatus && !binaryStatus[laneTool]?.found && (
{t("binary.missing")} @@ -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 })} /> ) : (
diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index df1d6ad9..4ccbdf4a 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -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) {
{resumePicked && (
- { - 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" ? ( + { + 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} + /> + ) : ( + + )}
)}
@@ -211,18 +232,44 @@ export function RunSetup(props: RunSetupProps) { - - - 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") }, - ]} - /> - + {props.tool === "codex" ? ( + <> + + + 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") }, + ]} + /> + + + + value={props.approval} + onChange={props.onApprovalChange} + options={[ + { value: "on-request", label: t("fields.approvalOnRequest") }, + { value: "never", label: t("fields.approvalNever") }, + ]} + /> + + + ) : ( + + + 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") }, + ]} + /> + + )} value={props.effort} @@ -236,7 +283,14 @@ export function RunSetup(props: RunSetupProps) { - {props.permissionMode === "bypassPermissions" && ( + {props.tool === "codex" && props.sandbox === "danger-full-access" && ( +
+ + {t("hint.sandboxWarning")} +
+ )} + + {props.tool === "claude" && props.permissionMode === "bypassPermissions" && (
{t("hint.permissionWarning")} @@ -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, }); } diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index 5fe2614e..c6e4403f 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -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, diff --git a/client/src/components/run/__tests__/RunSetup.test.tsx b/client/src/components/run/__tests__/RunSetup.test.tsx index 0aee7c0e..0ab3ff77 100644 --- a/client/src/components/run/__tests__/RunSetup.test.tsx +++ b/client/src/components/run/__tests__/RunSetup.test.tsx @@ -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> = onStart: vi.fn(), onResumeSessionChange: vi.fn(), onResumeFromHistory: vi.fn(), + onSandboxChange: vi.fn(), + onApprovalChange: vi.fn(), + onResumeLast: vi.fn(), }; const utils = render( @@ -64,6 +68,9 @@ function renderSetup(overrides: Partial> = 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()} /> ); @@ -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(); + }); +}); diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index 95198235..c2c78b43 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -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…", diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 5d97b413..cda1234d 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -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…", diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index b7ac7ba1..a12671cd 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -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 | 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([]); - const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( - null - ); + const [binaryStatus, setBinaryStatus] = useState | null>(null); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [defaultCwd, setDefaultCwd] = useState(""); 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()