feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts

- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
  carries the first prompt, `--continue` resumes; `agy` has no model,
  effort or permission flag, and `--dangerously-skip-permissions` is never
  emitted. No pipeline, no agents install, and no AI Router (native only,
  else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
  probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
  and the prompt box names the lane's CLI ("Ask Codex anything…"), via
  LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
  (healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
  hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
  continues the previous thread.
This commit is contained in:
2026-09-28 10:22:07 +07:00
parent 952e8584f0
commit 9d096b61f7
31 changed files with 257 additions and 93 deletions
@@ -44,6 +44,7 @@ import type {
CodexApproval,
} from "../../lib/api";
import type { Session, Lane, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
@@ -149,7 +150,7 @@ export function LaneConsolePane({
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
const toolLabel = LANE_TOOL_LABEL[laneTool];
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -568,7 +569,12 @@ export function LaneConsolePane({
{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", { cli: laneTool, tool: toolLabel })}</span>
<span>
{t("binary.missing", {
cli: laneTool === "antigravity" ? "agy" : laneTool,
tool: toolLabel,
})}
</span>
</div>
)}
+28 -22
View File
@@ -56,6 +56,7 @@ import type {
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { Select } from "../Select";
export interface SlashCommand {
@@ -430,10 +431,13 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
{/* `agy` takes no model, permission or effort flag. */}
{props.tool !== "antigravity" && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
@@ -472,17 +476,19 @@ export function RunSetup(props: RunSetupProps) {
/>
</Field>
)}
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
{props.tool !== "antigravity" && (
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
)}
</div>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
@@ -542,9 +548,9 @@ export function RunSetup(props: RunSetupProps) {
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
rows={5}
slash={props.tool !== "codex"}
slash={props.tool === "claude"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -609,18 +615,18 @@ export function RunSetup(props: RunSetupProps) {
function handleStart(props: RunSetupProps, session?: Session) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
const agy = props.tool === "antigravity";
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: codex ? undefined : props.permissionMode || undefined,
model: agy ? undefined : props.model || undefined,
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
effort: agy ? undefined : props.effort || undefined,
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
@@ -84,6 +84,7 @@ function baseProps() {
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
cwdSuggestions: [],
activeRuns: { items: [] },
@@ -380,7 +380,7 @@ describe("RunSetup — per-tool fields", () => {
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
const box = () => screen.getByPlaceholderText(/ anything…$/);
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
@@ -423,3 +423,26 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => {
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined })
);
});
});
describe("RunSetup — codex lanes", () => {
it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledOnce();
expect(spies.onStart.mock.calls[0][0].resumeLast).toBeUndefined();
});
});