f77049c238
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
633 lines
24 KiB
TypeScript
633 lines
24 KiB
TypeScript
/**
|
||
* @file AddLaneModal.tsx
|
||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||
* segmented toggle: "Repo" adopts an existing directory as-is via
|
||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||
* main repo you want stage detection on); "Worktree" provisions a
|
||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||
* manually-typed branch name. The user also picks which CLI drives the lane
|
||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||
* suggestions, or by browsing (`FolderBrowseModal`) — a native folder picker
|
||
* cannot hand a web page an absolute filesystem path, so browsing is
|
||
* server-backed instead. The worktree lane returned is `status: "provisioning"`:
|
||
* the route answers before the actual `git worktree add` runs, so this
|
||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { FolderOpen } from "lucide-react";
|
||
import { ConfirmModal } from "../ConfirmModal";
|
||
import { CwdAutocomplete } from "../run/RunSetup";
|
||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||
import { api } from "../../lib/api";
|
||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||
import type { Lane, LaneTool } from "../../lib/types";
|
||
|
||
/** Polls the lane until the background `git worktree add` finishes (status
|
||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||
* lane record, or `null` on timeout. */
|
||
async function waitForProvisioned(
|
||
laneId: number,
|
||
{ intervalMs = 500, timeoutMs = 30000 } = {}
|
||
): Promise<Lane | null> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
for (;;) {
|
||
const { lane } = await api.lanes.get(laneId);
|
||
if (lane.status !== "provisioning") return lane;
|
||
if (Date.now() >= deadline) return null;
|
||
await new Promise((r) => window.setTimeout(r, intervalMs));
|
||
}
|
||
}
|
||
|
||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||
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={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||
active
|
||
? "bg-accent text-white shadow-sm"
|
||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function AddLaneModal({
|
||
open,
|
||
onClose,
|
||
onAdded,
|
||
cwdSuggestions,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||
onAdded: (lane: Lane) => void;
|
||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||
cwdSuggestions: CwdSuggestion[];
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||
const [sourceRepo, setSourceRepo] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [branch, setBranch] = useState("");
|
||
const [branches, setBranches] = useState<string[] | null>(null);
|
||
const [base, setBase] = useState("");
|
||
const [pipeline, setPipeline] = useState("default");
|
||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||
const [router, setRouter] = useState("native");
|
||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||
const [tool, setTool] = useState<LaneTool>("claude");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [browseOpen, setBrowseOpen] = useState(false);
|
||
const [setupResult, setSetupResult] = useState<{
|
||
profile: "scaffolded" | "skipped" | "failed";
|
||
agents: "ok" | "failed";
|
||
mcp: "ok" | "failed";
|
||
router: "ok" | "skipped" | "failed";
|
||
} | null>(null);
|
||
|
||
const reset = () => {
|
||
setMode("worktree");
|
||
setSourceRepo("");
|
||
setTitle("");
|
||
setBranch("");
|
||
setBranches(null);
|
||
setBase("");
|
||
setPipeline("default");
|
||
setRouter("native");
|
||
setRoutersLoaded(false);
|
||
setTool("claude");
|
||
setBranchesError(null);
|
||
setError(null);
|
||
setBusy(false);
|
||
setSetupResult(null);
|
||
};
|
||
|
||
// The template a lane is created with is the ONLY chance to get it right
|
||
// from here: nothing else in the UI can change it afterwards, so a lane
|
||
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||
// Fetched on open (templates are file-backed and can change between opens).
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
let cancelled = false;
|
||
api.lanes
|
||
.pipelines()
|
||
.then((r) => {
|
||
if (!cancelled) setPipelines(r.pipelines);
|
||
})
|
||
.catch(() => {
|
||
// Quiet: the select just falls back to the single `default` option
|
||
// below, and the lane still gets created.
|
||
if (!cancelled) setPipelines([]);
|
||
});
|
||
api.aiRouters
|
||
.list()
|
||
.then((r) => {
|
||
if (cancelled) return;
|
||
setRouters(r.items);
|
||
// A profile deleted in another tab while this modal sat open would
|
||
// otherwise stay selected in state while the select showed no matching
|
||
// option — and submit would send a name the server no longer knows.
|
||
setRouter((current) =>
|
||
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
|
||
);
|
||
})
|
||
.catch(() => {
|
||
// Quiet: with no list the radio simply offers Native, and the lane is
|
||
// still created — a router is an optional extra, never a precondition.
|
||
if (!cancelled) setRouters([]);
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setRoutersLoaded(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open]);
|
||
|
||
// Look up the repo's branches once the path settles - debounced so every
|
||
// keystroke while typing a path doesn't fire a request against a path that
|
||
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
|
||
// never forks a branch.
|
||
const lookedUpFor = useRef<string>("");
|
||
useEffect(() => {
|
||
if (mode !== "worktree") return;
|
||
const path = sourceRepo.trim();
|
||
if (!path) {
|
||
setBranches(null);
|
||
setBase("");
|
||
setBranchesError(null);
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(async () => {
|
||
lookedUpFor.current = path;
|
||
try {
|
||
const r = await api.lanes.branches(path);
|
||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||
setBranches(r.branches);
|
||
setBase(r.current || r.branches[0] || "");
|
||
setBranchesError(null);
|
||
} catch {
|
||
if (lookedUpFor.current !== path) return;
|
||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||
setBranches(null);
|
||
setBase("");
|
||
setBranchesError(t("addLaneNotARepo"));
|
||
}
|
||
}, 300);
|
||
return () => window.clearTimeout(timer);
|
||
}, [mode, sourceRepo, t]);
|
||
|
||
// Native is the built-in profile, so this is one call either way — there is
|
||
// no "no router" branch to keep in step with the real one.
|
||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||
|
||
const submit = async () => {
|
||
const repo = sourceRepo.trim();
|
||
const name = title.trim();
|
||
if (!repo) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
|
||
if (mode === "repo") {
|
||
try {
|
||
const result = await api.lanes.ensure({
|
||
cwd: repo,
|
||
title: name || undefined,
|
||
pipeline,
|
||
tool,
|
||
});
|
||
// Report the lane the INSTANT it exists server-side. Everything after
|
||
// this is post-creation setup that can fail on its own, and letting one
|
||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||
// the list until some unrelated refetch happened to pick it up.
|
||
// Worktree mode below already works this way.
|
||
onAdded(result.lane);
|
||
|
||
// A session that booted on the vendor's API would keep talking to it
|
||
// for its whole life, so the router is applied before anything runs.
|
||
try {
|
||
await applyRouter(result.lane.id);
|
||
} catch (routerErr) {
|
||
// The lane exists and is already listed — name what did not finish
|
||
// instead of closing on a half-set-up lane.
|
||
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
reset();
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!branches || !name || !branch.trim()) return;
|
||
try {
|
||
const result = await api.lanes.worktree({
|
||
sourceRepo: repo,
|
||
title: name,
|
||
base: base || undefined,
|
||
branch: branch.trim(),
|
||
pipeline,
|
||
tool,
|
||
});
|
||
onAdded(result.lane);
|
||
|
||
// POST /worktree returns as soon as the DB row exists (202) — the
|
||
// actual `git worktree add` runs afterward, in the background, on the
|
||
// server. Firing setup against the lane's cwd before that finishes
|
||
// means agents/mcp write into a directory that doesn't exist yet, so
|
||
// wait for provisioning to leave the "provisioning" status first.
|
||
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||
if (!provisionedLane || provisionedLane.status === "failed") {
|
||
setBusy(false);
|
||
setError(t("addLaneProvisionFailed"));
|
||
return;
|
||
}
|
||
|
||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||
api.lanes.profileInit(result.lane.id),
|
||
api.lanes.agentsInstall(result.lane.id),
|
||
api.lanes.mcpSync(result.lane.id),
|
||
applyRouter(result.lane.id),
|
||
]);
|
||
const outcome = {
|
||
profile:
|
||
profileOutcome.status === "fulfilled"
|
||
? profileOutcome.value.scaffolded
|
||
? "scaffolded"
|
||
: "skipped"
|
||
: "failed",
|
||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||
router:
|
||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||
} as const;
|
||
const anyFailed = Object.values(outcome).includes("failed");
|
||
setSetupResult(outcome);
|
||
|
||
if (import.meta.env.DEV) {
|
||
console.info("[add-lane] auto-setup result:", {
|
||
profile:
|
||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||
// Never the profile itself: it would put an endpoint and a lane id in
|
||
// the browser console. (The key is never sent here at all.)
|
||
router: routerOutcome.status,
|
||
});
|
||
}
|
||
|
||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||
// dialog is done — leaving it up read as "still working" (the confirm
|
||
// button is dead from here, since the lane already exists) with no
|
||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||
// and then the confirm button becomes a plain close, because that is the
|
||
// one case where the user has something to read before dismissing it.
|
||
setBusy(false);
|
||
if (!anyFailed) {
|
||
reset();
|
||
onClose();
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
|
||
// identity. Every keystroke in the fields below re-renders this component; an
|
||
// inline `() => {...}` handed to `onCancel` would get a new identity each
|
||
// time, re-running that effect and yanking focus off the field being typed
|
||
// into after the very first character. useCallback keeps the identity stable
|
||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||
const handleCancel = useCallback(() => {
|
||
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
|
||
// While the folder browser is open on top of it, that dismissal should
|
||
// close only the browser, not both modals at once.
|
||
if (browseOpen) {
|
||
setBrowseOpen(false);
|
||
return;
|
||
}
|
||
reset();
|
||
onClose();
|
||
}, [onClose, browseOpen]);
|
||
|
||
// A summary on screen means the lane already exists and a step failed, so
|
||
// the confirm button has no second creation to run: it closes instead. That
|
||
// is the difference between a dialog that is finished and one that is stuck.
|
||
const done = !!setupResult;
|
||
const disabled =
|
||
!done &&
|
||
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
|
||
|
||
return (
|
||
<ConfirmModal
|
||
open={open}
|
||
title={t("addLane")}
|
||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||
cancelLabel={t("destructive.cancel")}
|
||
destructive={false}
|
||
busy={busy}
|
||
disabled={disabled}
|
||
onConfirm={done ? handleCancel : submit}
|
||
onCancel={handleCancel}
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||
<Seg
|
||
active={mode === "repo"}
|
||
label={t("mode.repo")}
|
||
title={t("mode.repoHint")}
|
||
onClick={() => setMode("repo")}
|
||
/>
|
||
<Seg
|
||
active={mode === "worktree"}
|
||
label={t("mode.worktree")}
|
||
title={t("mode.worktreeHint")}
|
||
onClick={() => setMode("worktree")}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||
<Seg
|
||
active={tool === "claude"}
|
||
label={t("addLaneToolClaude")}
|
||
onClick={() => setTool("claude")}
|
||
/>
|
||
<Seg
|
||
active={tool === "codex"}
|
||
label={t("addLaneToolCodex")}
|
||
onClick={() => setTool("codex")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||
</label>
|
||
<div className="flex gap-1.5">
|
||
<div className="min-w-0 flex-1">
|
||
<CwdAutocomplete
|
||
inputId="add-lane-repo"
|
||
value={sourceRepo}
|
||
onChange={setSourceRepo}
|
||
suggestions={cwdSuggestions}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBrowseOpen(true)}
|
||
title={t("browse.title")}
|
||
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
<FolderOpen className="h-3.5 w-3.5" />
|
||
{t("browse.button")}
|
||
</button>
|
||
</div>
|
||
<p className="mt-1 text-[10px] text-fg-muted">
|
||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||
{t("addLaneTitleLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder={t("addLaneTitlePlaceholder")}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||
{t("addLanePipelineLabel")}
|
||
</label>
|
||
<select
|
||
id="add-lane-pipeline"
|
||
value={pipeline}
|
||
onChange={(e) => setPipeline(e.target.value)}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||
>
|
||
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||
(p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||
</option>
|
||
)
|
||
)}
|
||
</select>
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||
</div>
|
||
|
||
<fieldset>
|
||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||
<div className="flex gap-2">
|
||
{(
|
||
[
|
||
{ value: "native", label: t("addLaneRouterNative") },
|
||
{ value: "router", label: t("addLaneRouterCustom") },
|
||
] as const
|
||
).map((option) => {
|
||
const active = option.value === "native" ? router === "native" : router !== "native";
|
||
// Clicking "AI Router" before the list lands would resolve back to
|
||
// Native and read as a dead control.
|
||
const disabled = option.value === "router" && !routersLoaded;
|
||
return (
|
||
<label
|
||
key={option.value}
|
||
// No radio dot: the card itself lights up when selected. That
|
||
// leaves the DISABLED state with nothing to show it, since the
|
||
// greyed-out dot was carrying it — hence the explicit branch.
|
||
// The input only goes visually hidden, so keyboard and screen
|
||
// readers still get a real radio group, and the card carries
|
||
// its focus ring.
|
||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||
disabled
|
||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||
: active
|
||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||
}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="add-lane-router"
|
||
className="sr-only"
|
||
disabled={disabled}
|
||
checked={active}
|
||
onChange={() =>
|
||
// Switching to "AI Router" lands on the first saved
|
||
// profile; with none saved the radio stays on native and
|
||
// the select below explains why.
|
||
setRouter(
|
||
option.value === "native"
|
||
? "native"
|
||
: routers.find((r) => !r.builtin)?.name || "native"
|
||
)
|
||
}
|
||
/>
|
||
{option.label}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{router !== "native" && (
|
||
<select
|
||
aria-label={t("addLaneRouterProfileLabel")}
|
||
value={router}
|
||
onChange={(e) => setRouter(e.target.value)}
|
||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||
>
|
||
{routers
|
||
.filter((r) => !r.builtin)
|
||
.map((r) => (
|
||
<option key={r.name} value={r.name}>
|
||
{r.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<p className="mt-1 text-[10px] text-fg-muted">
|
||
{!routersLoaded
|
||
? t("addLaneRouterLoading")
|
||
: routers.some((r) => !r.builtin)
|
||
? t("addLaneRouterHint")
|
||
: t("addLaneRouterEmptyHint")}
|
||
</p>
|
||
</fieldset>
|
||
|
||
{mode === "worktree" && branches && (
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||
{t("addLaneBaseLabel")}
|
||
</label>
|
||
{branches.length === 0 ? (
|
||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||
) : (
|
||
<select
|
||
id="add-lane-base"
|
||
value={base}
|
||
onChange={(e) => setBase(e.target.value)}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||
>
|
||
{branches.map((b) => (
|
||
<option key={b} value={b}>
|
||
{b}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
{mode === "worktree" && branchesError && !branches && (
|
||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||
)}
|
||
|
||
{mode === "worktree" && branches && (
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||
{t("addLaneBranchLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-branch"
|
||
value={branch}
|
||
onChange={(e) => setBranch(e.target.value)}
|
||
placeholder={t("addLaneBranchPlaceholder")}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||
</div>
|
||
)}
|
||
|
||
{setupResult && (
|
||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||
{(
|
||
[
|
||
{
|
||
label: t("addLaneSetupProfile"),
|
||
ok: setupResult.profile !== "failed",
|
||
skipped: setupResult.profile === "skipped",
|
||
},
|
||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||
{
|
||
label: t("addLaneSetupRouter"),
|
||
ok: setupResult.router !== "failed",
|
||
skipped: setupResult.router === "skipped",
|
||
},
|
||
] as const
|
||
).map((row) => (
|
||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||
<span
|
||
className={
|
||
"skipped" in row && row.skipped
|
||
? "text-fg-muted"
|
||
: row.ok
|
||
? "text-status-success"
|
||
: "text-status-danger"
|
||
}
|
||
>
|
||
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||
</span>
|
||
{row.label}
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p role="alert" className="text-xs text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<FolderBrowseModal
|
||
open={browseOpen}
|
||
initialPath={sourceRepo.trim() || undefined}
|
||
onSelect={setSourceRepo}
|
||
onClose={() => setBrowseOpen(false)}
|
||
/>
|
||
</ConfirmModal>
|
||
);
|
||
}
|