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
+13 -1
View File
@@ -429,6 +429,15 @@ export function AddLaneModal({
label={t("addLaneToolCodex")}
onClick={() => setTool("codex")}
/>
<Seg
active={tool === "antigravity"}
label={t("addLaneToolAntigravity")}
onClick={() => {
setTool("antigravity");
// `agy` cannot run behind an AI Router (see router-apply.js).
setRouter("native");
}}
/>
</div>
</div>
@@ -635,7 +644,10 @@ export function AddLaneModal({
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div
className="rounded-md border border-border-light bg-surface-0 p-2"
hidden={tool === "antigravity"}
>
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
+7 -14
View File
@@ -363,8 +363,8 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
role="menu"
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
{/* Claude-only: a codex lane has no agents to install. */}
{lane.tool !== "codex" && (
{/* Claude-only: other CLIs have no agents to install. */}
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
@@ -379,7 +379,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
{t("actions.agentsInstall")}
</button>
)}
{lane.tool !== "codex" && (
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
@@ -487,18 +487,11 @@ export default function LaneCard({
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{(lane.ci_status || lane.tool === "codex") && (
{lane.ci_status && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
{lane.ci_status && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
{lane.tool === "codex" && (
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
</div>
)}
@@ -18,6 +18,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
@@ -115,18 +116,18 @@ export default function LaneStripCard({
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
{/* Codex lanes carry no pipeline, so no stage chip — the Codex tag
takes its place. */}
{lane.tool === "codex" ? (
{/* Only claude lanes carry a pipeline; the others show their CLI's
tag in place of the stage chip. */}
{lane.tool !== "claude" ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
{LANE_TOOL_LABEL[lane.tool]}
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
)}
{lane.tool !== "codex" && detectionLeads(lane) && (
{lane.tool === "claude" && detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
@@ -96,13 +96,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
}
describe("LaneCard lane tool badge", () => {
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
// The strip tile already names the CLI; a card holding only that tag was an
// empty frame in the lane detail.
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
for (const tool of ["codex", "antigravity"] as const) {
const { container, unmount } = render(
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
);
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
unmount();
}
});
});