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, RunHandle,
RunListResponse, RunListResponse,
RunStartArgs, RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api"; } from "../../lib/api";
import type { Session, Lane } from "../../lib/types"; import type { Session, Lane, LaneTool } from "../../lib/types";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup"; import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory"; import { ActiveRunsSwitcher } from "./RunHistory";
@@ -40,7 +42,7 @@ export interface LaneConsolePaneProps {
showLaneSelector: boolean; showLaneSelector: boolean;
onLaneIdChange: (id: number) => void; onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void; onLaneCreated: (lane: Lane) => void;
binaryStatus: { found: boolean; path: string | null } | null; binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[]; cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the /** 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 [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]); 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 currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
useEffect(() => { useEffect(() => {
onHasActiveRunChange?.(handle !== null); onHasActiveRunChange?.(handle !== null);
@@ -239,6 +244,9 @@ export function LaneConsolePane({
permissionMode: args.permissionMode, permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId, resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined, effort: args.effort || undefined,
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
}); });
} catch (laneErr: unknown) { } catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr); const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
@@ -426,7 +434,7 @@ export function LaneConsolePane({
/> />
</header> </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"> <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" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span> <span>{t("binary.missing")}</span>
@@ -454,7 +462,7 @@ export function LaneConsolePane({
onPermissionModeChange={setPermissionMode} onPermissionModeChange={setPermissionMode}
effort={effort} effort={effort}
onEffortChange={setEffort} onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true} binaryFound={binaryStatus?.[laneTool]?.found ?? true}
busy={busy === "start"} busy={busy === "start"}
onStart={onStartFromSetup} onStart={onStartFromSetup}
activeRuns={activeRuns} activeRuns={activeRuns}
@@ -463,6 +471,12 @@ export function LaneConsolePane({
onResumeSessionChange={setResumeSession} onResumeSessionChange={setResumeSession}
runHistory={runHistory} runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory} 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"> <div className="flex-1 min-h-0 flex flex-col">
+87 -29
View File
@@ -47,8 +47,10 @@ import type {
EffortLevel, EffortLevel,
PermissionMode, PermissionMode,
RunStartArgs, RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api"; } from "../../lib/api";
import type { Session } from "../../lib/types"; import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select"; import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole) // Minimal SlashCommand type (from deleted RunConsole)
@@ -117,6 +119,15 @@ interface RunSetupProps {
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[]; runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void; 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) { export function RunSetup(props: RunSetupProps) {
@@ -154,19 +165,29 @@ export function RunSetup(props: RunSetupProps) {
</div> </div>
{resumePicked && ( {resumePicked && (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<SessionPicker {props.tool === "claude" ? (
selected={props.resumeSession} <SessionPicker
onSelect={(s) => { selected={props.resumeSession}
props.onResumeSessionChange(s); onSelect={(s) => {
// Pass the picked session explicitly: the parent's state props.onResumeSessionChange(s);
// update has not landed yet on this tick, so reading // Pass the picked session explicitly: the parent's state
// props.resumeSession here would resume nothing. // update has not landed yet on this tick, so reading
if (s && !props.busy) handleStart(props, s); // 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. // Fall back to the typed cwd when no lane is selected — otherwise
cwd={props.laneCwd || props.cwd.trim() || undefined} // 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>
)} )}
</div> </div>
@@ -211,18 +232,44 @@ export function RunSetup(props: RunSetupProps) {
<Field label={t("fields.model")}> <Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} /> <ModelPicker value={props.model} onChange={props.onModelChange} />
</Field> </Field>
<Field label={t("fields.permissionMode")}> {props.tool === "codex" ? (
<Select<PermissionMode> <>
value={props.permissionMode} <Field label={t("fields.sandbox")}>
onChange={props.onPermissionModeChange} <Select<CodexSandbox>
options={[ value={props.sandbox}
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") }, onChange={props.onSandboxChange}
{ value: "default", label: t("fields.permissionDefault") }, options={[
{ value: "plan", label: t("fields.permissionPlan") }, { value: "read-only", label: t("fields.sandboxReadOnly") },
{ value: "bypassPermissions", label: t("fields.permissionBypass") }, { value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
]} { value: "danger-full-access", label: t("fields.sandboxFullAccess") },
/> ]}
</Field> />
</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")}> <Field label={t("fields.effort")}>
<Select<EffortLevel> <Select<EffortLevel>
value={props.effort} value={props.effort}
@@ -236,7 +283,14 @@ export function RunSetup(props: RunSetupProps) {
</Field> </Field>
</div> </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"> <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" /> <ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.permissionWarning")}</span> <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. */ * out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session) { function handleStart(props: RunSetupProps, session?: Session) {
const resume = session ?? props.resumeSession; const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
props.onStart({ props.onStart({
laneId: props.laneId, laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked // 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 field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined, cwd: resume?.cwd || props.cwd || undefined,
model: props.model || 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, effort: props.effort || undefined,
resumeSessionId: resume?.id || undefined, resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
initialPrompt: props.prompt || undefined, initialPrompt: props.prompt || undefined,
}); });
} }
@@ -74,7 +74,10 @@ function baseProps() {
showLaneSelector: false, showLaneSelector: false,
onLaneIdChange: vi.fn(), onLaneIdChange: vi.fn(),
onLaneCreated: 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: [], cwdSuggestions: [],
activeRuns: { items: [] }, activeRuns: { items: [] },
wsConnected: true, wsConnected: true,
@@ -12,6 +12,7 @@
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { RunSetup } from "../RunSetup"; import { RunSetup } from "../RunSetup";
@@ -47,6 +48,9 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
onStart: vi.fn(), onStart: vi.fn(),
onResumeSessionChange: vi.fn(), onResumeSessionChange: vi.fn(),
onResumeFromHistory: vi.fn(), onResumeFromHistory: vi.fn(),
onSandboxChange: vi.fn(),
onApprovalChange: vi.fn(),
onResumeLast: vi.fn(),
}; };
const utils = render( const utils = render(
<MemoryRouter> <MemoryRouter>
@@ -64,6 +68,9 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
resumeSession={null} resumeSession={null}
slashCommands={[]} slashCommands={[]}
runHistory={[]} runHistory={[]}
tool="claude"
sandbox="workspace-write"
approval="on-request"
{...spies} {...spies}
{...overrides} {...overrides}
/> />
@@ -273,6 +280,9 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]} slashCommands={[]}
runHistory={[]} runHistory={[]}
laneCwd="/Users/tester/lane-b" laneCwd="/Users/tester/lane-b"
tool="claude"
sandbox="workspace-write"
approval="on-request"
onPromptChange={vi.fn()} onPromptChange={vi.fn()}
onCwdChange={vi.fn()} onCwdChange={vi.fn()}
onModelChange={vi.fn()} onModelChange={vi.fn()}
@@ -281,6 +291,9 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
onStart={vi.fn()} onStart={vi.fn()}
onResumeSessionChange={vi.fn()} onResumeSessionChange={vi.fn()}
onResumeFromHistory={vi.fn()} onResumeFromHistory={vi.fn()}
onSandboxChange={vi.fn()}
onApprovalChange={vi.fn()}
onResumeLast={vi.fn()}
/> />
</MemoryRouter> </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" "you": "You"
}, },
"fields": { "fields": {
"approval": "Approval",
"approvalNever": "Never",
"approvalOnRequest": "On request",
"cwd": "Working directory", "cwd": "Working directory",
"cwdGroups": { "cwdGroups": {
"dashboard": "Dashboard server", "dashboard": "Dashboard server",
@@ -75,7 +78,11 @@
"permissionPlan": "plan (read-only planning)", "permissionPlan": "plan (read-only planning)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…", "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": { "footer": {
"cost": "Cost", "cost": "Cost",
@@ -87,6 +94,7 @@
"hint": { "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.", "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.", "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" "shortcut": "Cmd+Enter / Ctrl+Enter to send"
}, },
"limitations": { "limitations": {
@@ -104,6 +112,7 @@
"freshHint": "Start a fresh Claude Code session.", "freshHint": "Start a fresh Claude Code session.",
"freshOption": "New session", "freshOption": "New session",
"label": "Source session", "label": "Source session",
"lastOption": "Resume last session",
"noSessions": "No sessions found.", "noSessions": "No sessions found.",
"originalCwd": "Original cwd", "originalCwd": "Original cwd",
"pickSession": "Pick a session…", "pickSession": "Pick a session…",
+10 -1
View File
@@ -53,6 +53,9 @@
"you": "Bạn" "you": "Bạn"
}, },
"fields": { "fields": {
"approval": "Phê duyệt",
"approvalNever": "Không bao giờ",
"approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc", "cwd": "Thư mục làm việc",
"cwdGroups": { "cwdGroups": {
"dashboard": "Dashboard server", "dashboard": "Dashboard server",
@@ -74,7 +77,11 @@
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…", "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": { "footer": {
"cost": "Chi phí", "cost": "Chi phí",
@@ -86,6 +93,7 @@
"hint": { "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.", "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.", "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" "shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
}, },
"limitations": { "limitations": {
@@ -103,6 +111,7 @@
"freshHint": "Bắt đầu phiên Claude Code mới.", "freshHint": "Bắt đầu phiên Claude Code mới.",
"freshOption": "Phiên mới", "freshOption": "Phiên mới",
"label": "Phiên nguồn", "label": "Phiên nguồn",
"lastOption": "Tiếp tục phiên trước",
"noSessions": "Không tìm thấy phiên.", "noSessions": "Không tìm thấy phiên.",
"originalCwd": "cwd gốc", "originalCwd": "cwd gốc",
"pickSession": "Chọn phiên…", "pickSession": "Chọn phiên…",
+18 -10
View File
@@ -44,7 +44,15 @@ import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } 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 { eventBus } from "../lib/eventBus";
import { LaneConsolePane } from "../components/run/LaneConsolePane"; import { LaneConsolePane } from "../components/run/LaneConsolePane";
import PipelineMap from "../components/lanes/PipelineMap"; import PipelineMap from "../components/lanes/PipelineMap";
@@ -109,7 +117,7 @@ function ConsoleArea({
splitView: SplitViewState; splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void; setLayout: (layout: SplitLayout) => void;
setPaneLaneId: (index: number, id: number) => 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[]; cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
externalSessions: Session[]; externalSessions: Session[];
@@ -192,9 +200,10 @@ export function Workspace() {
// started in a terminal tab). They are not dashboard runs, so the run list // started in a terminal tab). They are not dashboard runs, so the run list
// would otherwise show nothing while two agents are working. // would otherwise show nothing while two agents are working.
const [externalSessions, setExternalSessions] = useState<Session[]>([]); const [externalSessions, setExternalSessions] = useState<Session[]>([]);
const [binaryStatus, setBinaryStatus] = useState<{ found: boolean; path: string | null } | null>( const [binaryStatus, setBinaryStatus] = useState<Record<
null LaneTool,
); { found: boolean; path: string | null }
> | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]); const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>(""); const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
@@ -241,12 +250,11 @@ export function Workspace() {
}, []); }, []);
useEffect(() => { useEffect(() => {
api.run Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.binary() .then(([claude, codex]) => setBinaryStatus({ claude, codex }))
.then(setBinaryStatus)
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof // Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` is missing from PATH — leave the probe unresolved rather than // `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "claude missing" banner for an unrelated fault. // showing a misleading "binary missing" banner for an unrelated fault.
.catch(() => undefined); .catch(() => undefined);
api.lanes api.lanes
.pipelines() .pipelines()