refactor(workspace): drop the page header, move Add lane and Tools onto the lanes
The title and the four counters said a second time what the lane strip already shows lane by lane (liveness dot, ⚠ marker), so they only cost a row of height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers. Add lane becomes the strip's FIRST tile — creating a lane is the same kind of act as picking one — and the per-lane Tools menu (agents install / mcp sync / sync-base check) moves onto the lane-detail header row beside the pipeline picker and the Model select, next to the lane it acts on.
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
* @file Workspace.tsx
|
* @file Workspace.tsx
|
||||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||||
@@ -45,15 +45,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
import { Plus, Square, Columns2, Grid2x2 } 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 {
|
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||||
Lane,
|
|
||||||
LaneFeature,
|
|
||||||
LaneCounts,
|
|
||||||
ProofFeature,
|
|
||||||
Session,
|
|
||||||
WSMessage,
|
|
||||||
LaneTool,
|
|
||||||
} from "../lib/types";
|
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
@@ -203,7 +195,6 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Lane state
|
// Lane state
|
||||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
|
||||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||||
@@ -263,7 +254,6 @@ export function Workspace() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.lanes.list();
|
const r = await api.lanes.list();
|
||||||
setLanes(keepIfSame(r.lanes));
|
setLanes(keepIfSame(r.lanes));
|
||||||
setCounts(keepIfSame(r.counts));
|
|
||||||
setSelectedLaneId((cur) =>
|
setSelectedLaneId((cur) =>
|
||||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||||
);
|
);
|
||||||
@@ -515,60 +505,25 @@ export function Workspace() {
|
|||||||
: "space-y-5"
|
: "space-y-5"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Page header: what this screen is, and the four numbers that say
|
|
||||||
whether anything needs a human right now. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
|
||||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
|
||||||
{tLanes("title")}
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span
|
|
||||||
data-testid="count-total"
|
|
||||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
|
||||||
>
|
|
||||||
{counts.total} {tLanes("countTotal")}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-testid="count-running"
|
|
||||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
|
||||||
>
|
|
||||||
{counts.running} {tLanes("countRunning")}
|
|
||||||
</span>
|
|
||||||
{counts.needs_you > 0 && (
|
|
||||||
<span
|
|
||||||
data-testid="count-needs-you"
|
|
||||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
|
||||||
>
|
|
||||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{counts.dead > 0 && (
|
|
||||||
<span
|
|
||||||
data-testid="count-dead"
|
|
||||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
|
||||||
>
|
|
||||||
{counts.dead} {tLanes("countDead")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
|
||||||
<button
|
|
||||||
onClick={() => setAddLaneOpen(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
|
||||||
title={tLanes("addLane")}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{tLanes("add")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||||
scrolling row (holds any number of lanes), the detail/console area
|
scrolling row (holds any number of lanes), the detail/console area
|
||||||
below it gets the full width and never competes for it. */}
|
below it gets the full width and never competes for it. */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||||
|
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||||
|
creating a lane is the same kind of act as picking one, so it
|
||||||
|
reads as the empty slot at the head of the row. Dashed border and
|
||||||
|
no liveness dot keep it from being mistaken for a lane. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-tile-add"
|
||||||
|
onClick={() => setAddLaneOpen(true)}
|
||||||
|
title={tLanes("addLane")}
|
||||||
|
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{tLanes("add")}
|
||||||
|
</button>
|
||||||
{lanes.map((l) => (
|
{lanes.map((l) => (
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
key={l.id}
|
key={l.id}
|
||||||
@@ -690,6 +645,9 @@ export function Workspace() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||||
|
belong beside the lane they act on, not in a page header. */}
|
||||||
|
<LaneToolsMenu lane={currentLane} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-detail-toggle"
|
data-testid="lane-detail-toggle"
|
||||||
|
|||||||
@@ -328,19 +328,11 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace page — lane integration", () => {
|
describe("Workspace page — lane integration", () => {
|
||||||
it("lane strip lists lanes and counters match the API response", async () => {
|
it("lane strip lists the lanes the API returned", async () => {
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
|
||||||
// Verify lanes are rendered
|
|
||||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||||
|
|
||||||
// Verify counters are rendered with correct values
|
|
||||||
const counterTexts = screen.getAllByText((_, element) => {
|
|
||||||
if (!element) return false;
|
|
||||||
return element.textContent?.includes("2 lanes") || false;
|
|
||||||
});
|
|
||||||
expect(counterTexts.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selecting a lane switches the pipeline and run id", async () => {
|
it("selecting a lane switches the pipeline and run id", async () => {
|
||||||
@@ -497,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace layout", () => {
|
describe("Workspace layout", () => {
|
||||||
it("shows the four counters with the values the API reported", async () => {
|
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
const user = userEvent.setup();
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
|
||||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
const strip = screen.getByTestId("lane-strip");
|
||||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
await user.click(screen.getByTestId("lane-tile-add"));
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||||
await renderWorkspace();
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives every lane a tile in the carousel", async () => {
|
it("gives every lane a tile in the carousel", async () => {
|
||||||
|
|||||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||||
>
|
>
|
||||||
<h2
|
|
||||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
|
||||||
>
|
|
||||||
Lanes
|
|
||||||
</h2>
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||||
>
|
data-testid="lane-strip"
|
||||||
<span
|
|
||||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
|
||||||
data-testid="count-total"
|
|
||||||
>
|
|
||||||
0
|
|
||||||
|
|
||||||
lanes
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
|
||||||
data-testid="count-running"
|
|
||||||
>
|
|
||||||
0
|
|
||||||
|
|
||||||
running
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="ml-auto flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||||
|
data-testid="lane-tile-add"
|
||||||
title="Create a lane from a working directory"
|
title="Create a lane from a working directory"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-plus h-3.5 w-3.5"
|
class="lucide lucide-plus h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
Add lane
|
Add lane
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
|
||||||
data-testid="lane-strip"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="text-sm text-fg-muted"
|
class="text-sm text-fg-muted"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user