diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 93f5487c..6d5396a9 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -1,13 +1,16 @@ /** - * @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR - * facts, the "needs you" banner sourced from Claude Code's Notification hook, - * and the control row — which ends in the two deletions (lane, history) as - * plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving - * session went silent while it should have been working) is called out - * loudly — that is the failure this view exists to catch. Stage, kind, and - * the "auto: " chip are NOT repeated here — this card only ever - * renders inside the Workspace lane-detail section, whose header above it - * already shows them. + * @file One lane's card: progress bar, branch/CI/PR facts, the "needs you" + * banner sourced from Claude Code's Notification hook, and (for managed + * lanes only) a ⋯ menu holding the rare `reset worktree` action behind + * DestructiveLaneModal. A dead lane (its driving session went silent while + * it should have been working) is called out loudly — that is the failure + * this view exists to catch. Stage, kind, and the "auto: " chip are + * NOT repeated here — this card only ever renders inside the Workspace + * lane-detail section, whose header above it already shows them. The repo + * tooling dropdown (`LaneToolsMenu`, exported from this file) and the + * remove-lane button live in Workspace's page header / pipeline-picker row + * instead of here, so they stay reachable and un-clipped regardless of this + * card's own scroll area. * @author Nguyễn Ngọc Trí Vĩ */ @@ -73,7 +76,7 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null { * `bump` lets an up/down action re-read immediately instead of waiting out the * poll interval. */ -function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null { +export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null { const [runtime, setRuntime] = useState(null); useEffect(() => { @@ -164,78 +167,34 @@ function useLaneIntegrations( return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null; } -const LIVENESS_DOT: Record = { +export const LIVENESS_DOT: Record = { active: "bg-status-success", idle: "bg-surface-4", dead: "bg-status-danger", }; -function since(sec: number | null): string { +export function since(sec: number | null): string { if (sec === null) return "—"; if (sec < 60) return `${sec}s`; if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; } -export default function LaneCard({ - lane, - onAction, - childWorktrees, - onSelectLane, -}: { - lane: Lane; - onAction: (action: string, body?: Record) => void; - /** Other lanes whose `source_repo` is this lane's `cwd` — populated only - * when this lane is itself a source repo (typically an adopted one) that - * other lanes were provisioned as worktrees from. */ - childWorktrees?: Lane[]; - /** Jumps the Workspace page's selection to another lane's card. */ - onSelectLane?: (id: number) => void; -}) { +/** + * Repo tooling (agents install / MCP sync / dev-sync check) for one lane, + * collapsed into a single dropdown. Lives outside LaneCard so it can be + * placed in the Workspace page header, next to "Add lane" — it polls its + * own runtime state independently of LaneCard's, same pattern as the + * separately-polled git/locks facts elsewhere on this page. + */ +export function LaneToolsMenu({ lane }: { lane: Lane }) { const { t } = useTranslation(["lanes"]); - const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>( - null - ); - const [menuOpen, setMenuOpen] = useState(false); - const [runtimeBump, setRuntimeBump] = useState(0); - const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null); - const [bootLine, setBootLine] = useState(null); + const [toolsMenuOpen, setToolsMenuOpen] = useState(false); const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>( null ); const [laneActionResult, setLaneActionResult] = useState(null); - const git = useLaneGitFacts(lane.id); - const runtime = useLaneRuntime(lane.id, runtimeBump); - const locks = useLaneLocks(lane.slot); - const integrations = useLaneIntegrations(lane.id, runtime?.available === true); - - /** - * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`: - * that prop drives the lane's Claude run, and folding a second lifecycle into - * it would make "stop" ambiguous about which thing it stops. - * - * `up` answers 202 and keeps booting in the background, so the button stays - * busy until the server's `lane_runtime` message says the attempt finished — - * resolving the request is not the same as the stack being up. - */ - const runtimeAction = async (which: "up" | "down") => { - setRuntimeBusy(which); - setBootLine(null); - try { - if (which === "down") { - await api.lanes.down(lane.id); - setRuntimeBusy(null); - } else { - await api.lanes.up(lane.id); - } - } catch { - // The failure surfaces as the runtime row's lastError on the next read; a - // toast here would say the same thing twice. - setRuntimeBusy(null); - } finally { - setRuntimeBump((n) => n + 1); - } - }; + const runtime = useLaneRuntime(lane.id, 0); const runLaneAction = async ( which: "agents" | "mcp" | "sync-check", @@ -277,6 +236,107 @@ export default function LaneCard({ }); }); + if (!runtime?.available) return null; + + return ( +
+ + {toolsMenuOpen && ( +
+ + + +
+ )} + {laneActionResult && ( +

+ {laneActionResult} +

+ )} +
+ ); +} + +export default function LaneCard({ + lane, + onAction, + childWorktrees, + onSelectLane, +}: { + lane: Lane; + onAction: (action: string, body?: Record) => void; + /** Other lanes whose `source_repo` is this lane's `cwd` — populated only + * when this lane is itself a source repo (typically an adopted one) that + * other lanes were provisioned as worktrees from. */ + childWorktrees?: Lane[]; + /** Jumps the Workspace page's selection to another lane's card. */ + onSelectLane?: (id: number) => void; +}) { + const { t } = useTranslation(["lanes"]); + const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null); + const [menuOpen, setMenuOpen] = useState(false); + const [runtimeBump, setRuntimeBump] = useState(0); + const [bootLine, setBootLine] = useState(null); + const git = useLaneGitFacts(lane.id); + const runtime = useLaneRuntime(lane.id, runtimeBump); + const locks = useLaneLocks(lane.slot); + const integrations = useLaneIntegrations(lane.id, runtime?.available === true); + /** * Live boot feedback. A build can run for minutes, and a card showing only a * disabled button through all of it reads as a hang. The hook's own output @@ -291,7 +351,6 @@ export default function LaneCard({ setBootLine(data.line); } if (msg.type === "lane_runtime") { - setRuntimeBusy(null); setBootLine(null); setRuntimeBump((n) => n + 1); } @@ -303,52 +362,27 @@ export default function LaneCard({ <>
- {/* Identity strip: which lane, and is it alive. Kept on one line and in - uppercase so a wall of cards can be scanned vertically. */} -
- - {t("cardId", { id: lane.id })} - - - - {/* i18next returns the KEY on a miss, so `|| raw` never fires and a - non-standard status rendered as the literal "status.foo". - defaultValue makes it degrade to the raw status instead. */} - {lane.liveness === "dead" - ? t("statusDead") - : t(`status.${lane.status}`, { defaultValue: lane.status })} - -
- -

- {lane.title || lane.cwd} -

- - {/* Progress line: how far through the pipeline and how long the - current stage has been sitting there. The stage's own name is - already in the Workspace header above; not repeated here. */} -
-
0 ? "bg-surface-2" : "bg-transparent" - }`} - > -
-
- {lane.progress > 0 && ( + {/* Progress line: how far through the pipeline. The stage's own name + and how long it's been running are already in the Workspace + header above; not repeated here. Omitted at 0% — an empty bar + is not information, just a blank row above a dead/idle lane. */} + {lane.progress > 0 && ( +
+
+
+
{lane.progress}% - )} - {since(lane.stage_seconds)} -
+
+ )} {lane.ci_status && ( -
+
CI {lane.ci_status} @@ -356,7 +390,7 @@ export default function LaneCard({ )} {lane.needs_action && ( -
+
⚠ {lane.needs_action}
)} @@ -368,7 +402,7 @@ export default function LaneCard({ {runtime?.available && runtime.provisioned && (
{t("runtime.slot", { slot: runtime.slot })} @@ -427,7 +461,7 @@ export default function LaneCard({ {locks.length > 0 && (
`${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")} > @@ -435,7 +469,7 @@ export default function LaneCard({
)} -
+
{git?.available && (
@@ -463,7 +497,7 @@ export default function LaneCard({ {childWorktrees && childWorktrees.length > 0 && (
{t("worktrees.heading", { count: childWorktrees.length })} @@ -486,7 +520,7 @@ export default function LaneCard({ )} {integrations && ( -
+
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => ( - {(["start", "stop", "clear"] as const).map((a) => ( - - ))} - {/* Stack controls, only for a lane whose repo declares a profile. - Separate from start/stop above: those drive the lane's Claude run, - these drive the application it is working on. */} - {runtime?.available && ( - - )} - {runtime?.available && ( - <> + in one grid row still line their buttons up. Repo tooling and the + remove button live in the Workspace page header/pipeline row now — + `reset` is the one destructive action left here, since it only + applies to managed lanes and is rare enough to stay behind ⋯. */} + {lane.kind === "managed" && ( +
+
- - - - )} - {/* Deleting the lane and deleting its history are each their own - button, by request: hiding "delete" behind a ⋯ made it unfindable, - and the one label that read as "delete" was `clear`. Neither fires - directly — both open DestructiveLaneModal, which shows the exact - counts and demands a confirmation. `reset` stays in the menu; it - is the rare one and only exists for managed lanes. */} -
- - - {/* Adopted lanes have no worktree to reset, so the menu would be - empty — it is not rendered at all rather than opening onto - nothing. */} - {lane.kind === "managed" && ( -
- - {menuOpen && ( -
{ + e.stopPropagation(); + setMenuOpen(false); + setDestructiveAction("reset"); + }} + className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10" > - -
- )} -
- )} + {t("action.reset")} + +
+ )} +
-
- - {laneActionResult && ( -

- {laneActionResult} -

)}
diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 6320d153..d3dd64fd 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import LaneCard from "../LaneCard"; +import LaneCard, { LaneToolsMenu } from "../LaneCard"; import type { Lane, LaneRuntime } from "../../../lib/types"; import { api } from "../../../lib/api"; @@ -85,16 +85,6 @@ function makeLane(overrides: Partial = {}): Lane { } as Lane; } -describe("LaneCard status badge", () => { - for (const status of ["idle", "running", "provisioning", "failed"] as const) { - it(`renders a real word for status "${status}", not the raw key`, () => { - render(); - expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument(); - expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument(); - }); - } -}); - describe("LaneCard child worktrees", () => { it("lists worktrees provisioned from this lane and jumps to one on click", async () => { const onSelectLane = vi.fn(); @@ -148,10 +138,9 @@ describe("LaneCard rebuilt layout", () => { expect(screen.getByTestId("lane-card-7")).toBeInTheDocument(); }); - it("shows the progress percentage and the time on stage", () => { + it("shows the progress percentage", () => { render(); expect(screen.getByText("48%")).toBeInTheDocument(); - expect(screen.getByText("2m 32s")).toBeInTheDocument(); }); it("gives the progress bar a width matching the lane's progress", () => { @@ -164,35 +153,14 @@ describe("LaneCard rebuilt layout", () => { expect(screen.getByText(/waiting on approval/)).toBeInTheDocument(); }); - it("fires a plain action with its own name", async () => { - const onAction = vi.fn(); - render(); - await userEvent.setup().click(screen.getByTestId("lane-action-stop")); - expect(onAction).toHaveBeenCalledWith("stop"); - }); - - it("shows both deletions as buttons, and keeps reset behind the menu", async () => { + it("keeps reset behind the ⋯ menu rather than showing it directly", async () => { render(); - // Deleting the lane and deleting its history are the two the user goes - // looking for, so they are visible without opening anything. Reset is not. - expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument(); - expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument(); expect(screen.queryByTestId("lane-action-reset")).toBeNull(); await userEvent.setup().click(screen.getByTestId("lane-more")); expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); }); - it("routes both deletions through the modal rather than firing them", async () => { - const onAction = vi.fn(); - render(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("lane-action-remove")); - expect(onAction).not.toHaveBeenCalled(); - await user.click(screen.getByTestId("lane-action-purge")); - expect(onAction).not.toHaveBeenCalled(); - }); - it("routes reset through the confirmation modal rather than firing it", async () => { const onAction = vi.fn(); render(); @@ -202,18 +170,17 @@ describe("LaneCard rebuilt layout", () => { expect(onAction).not.toHaveBeenCalled(); }); - it("offers reset only for a managed lane, and drops the menu entirely without it", async () => { + it("offers the ⋯ menu only for a managed lane", async () => { const user = userEvent.setup(); const { unmount } = render(); await user.click(screen.getByTestId("lane-more")); expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); unmount(); - // An adopted lane has nothing left in the menu, so there is no ⋯ to open. + // An adopted lane has no worktree to reset, so there is no ⋯ to open. render(); expect(screen.queryByTestId("lane-more")).toBeNull(); expect(screen.queryByTestId("lane-action-reset")).toBeNull(); - expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument(); }); }); @@ -249,17 +216,17 @@ describe("LaneCard git block", () => { it("renders the card with no git block and no error when git is unavailable", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false }); - render(); + render(); - expect(await screen.findByText("plain dir lane")).toBeInTheDocument(); + expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument(); expect(screen.queryByTestId("lane-git")).toBeNull(); }); it("swallows a rejected request instead of surfacing an error", async () => { vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down")); - render(); + render(); - expect(await screen.findByText("offline lane")).toBeInTheDocument(); + expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument(); expect(screen.queryByTestId("lane-git")).toBeNull(); expect(screen.queryByText(/network down/)).toBeNull(); }); @@ -299,11 +266,10 @@ describe("LaneCard — the lane's own application stack", () => { it("shows nothing at all for a lane whose repo declares no profile", async () => { vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); - render(); + render(); - expect(await screen.findByText("no profile")).toBeInTheDocument(); + expect(await screen.findByTestId("lane-card-1")).toBeInTheDocument(); expect(screen.queryByTestId("lane-runtime-1")).toBeNull(); - expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull(); }); it("lists each declared port with its listening state", async () => { @@ -327,42 +293,6 @@ describe("LaneCard — the lane's own application stack", () => { expect(screen.getByText(/8003/)).toBeInTheDocument(); }); - it("stops the stack through /down, never through the run's onAction prop", async () => { - vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime); - vi.mocked(api.lanes.down).mockResolvedValue({ - ok: true, - killed: [4242], - runtime: { available: false }, - }); - const onAction = vi.fn(); - render(); - - await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle")); - - await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1)); - expect(api.lanes.up).not.toHaveBeenCalled(); - expect(onAction).not.toHaveBeenCalled(); - }); - - it("boots a provisioned-but-down lane and stays busy past the 202", async () => { - vi.mocked(api.lanes.runtime).mockResolvedValue({ - ...upRuntime, - services: [{ name: "web", pid: 4242, alive: false }], - ports: { api: { port: 8003, expected: 8003, listening: false } }, - up: false, - healthy: false, - }); - vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 }); - render(); - - await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle")); - - await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1)); - // The request resolving is not the stack being up: the server answered 202 - // and is still booting, so the button must not go idle yet. - await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled()); - }); - it("surfaces the last boot error when nothing is streaming", async () => { vi.mocked(api.lanes.runtime).mockResolvedValue({ ...upRuntime, @@ -398,7 +328,7 @@ describe("LaneCard — named locks", () => { }); }); -describe("agents install / mcp sync / integration badges / sync check", () => { +describe("LaneCard — integration badges", () => { beforeEach(() => { // Only the fields this describe block's assertions read; `as LaneRuntime` // keeps the double from having to restate a shape the component never @@ -424,13 +354,33 @@ describe("agents install / mcp sync / integration badges / sync check", () => { expect(devQc.className).not.toContain("status-success"); }); + it("hides integration badges when the lane has no profile", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); + render(); + await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled()); + expect(screen.queryByTestId("lane-integration-tracker")).toBeNull(); + }); +}); + +describe("LaneToolsMenu", () => { + beforeEach(() => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ + available: true as const, + provisioned: true as const, + up: false, + slot: 1, + profileDir: "/work/demo/.ccam/profile", + ports: {}, + } as unknown as LaneRuntime); + }); + it("clicking Install agents calls the API and shows the result", async () => { vi.mocked(api.lanes.agentsInstall).mockResolvedValue({ installed: ["qc-local.md", "senior-gate-reviewer.md"], }); - render(); - const button = await screen.findByTestId("lane-agents-install"); - await userEvent.click(button); + render(); + await userEvent.click(await screen.findByTestId("lane-tools-menu")); + await userEvent.click(await screen.findByTestId("lane-agents-install")); await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1)); expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md"); }); @@ -440,9 +390,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => { servers: ["playwright"], profilesSeeded: [], }); - render(); - const button = await screen.findByTestId("lane-mcp-sync"); - await userEvent.click(button); + render(); + await userEvent.click(await screen.findByTestId("lane-tools-menu")); + await userEvent.click(await screen.findByTestId("lane-mcp-sync")); await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1)); expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright"); }); @@ -453,9 +403,9 @@ describe("agents install / mcp sync / integration badges / sync check", () => { devDelta: ["a.txt", "b.txt"], overlap: [], }); - render(); - const button = await screen.findByTestId("lane-sync-check"); - await userEvent.click(button); + render(); + await userEvent.click(await screen.findByTestId("lane-tools-menu")); + await userEvent.click(await screen.findByTestId("lane-sync-check")); await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1)); expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2"); }); @@ -465,19 +415,16 @@ describe("agents install / mcp sync / integration badges / sync check", () => { code: 5, collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }], }); - render(); - const button = await screen.findByTestId("lane-sync-check"); - await userEvent.click(button); + render(); + await userEvent.click(await screen.findByTestId("lane-tools-menu")); + await userEvent.click(await screen.findByTestId("lane-sync-check")); expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql"); }); - it("hides all four additions when the lane has no profile", async () => { + it("renders nothing when the lane has no profile", async () => { vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); - render(); + const { container } = render(); await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled()); - expect(screen.queryByTestId("lane-agents-install")).toBeNull(); - expect(screen.queryByTestId("lane-mcp-sync")).toBeNull(); - expect(screen.queryByTestId("lane-sync-check")).toBeNull(); - expect(screen.queryByTestId("lane-integration-tracker")).toBeNull(); + expect(container).toBeEmptyDOMElement(); }); }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 2b693cfa..e394c691 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -1,6 +1,6 @@ { "action.clear": "clear state", - "action.forget": "delete lane", + "action.forget": "Delete lane", "action.purge": "delete history", "action.remove": "delete lane + worktree", "action.reset": "reset worktree", @@ -16,6 +16,7 @@ "actions.syncCheck": "Check dev sync", "actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}", "actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}", + "actions.tools": "Tools", "add": "Add lane", "addLane": "Create a lane from a working directory", "addLaneBaseLabel": "Branch to fork from", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 8601d5f7..c11e9169 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -1,6 +1,6 @@ { "action.clear": "Dọn trạng thái", - "action.forget": "Xóa làn", + "action.forget": "Xóa lane", "action.purge": "Xóa lịch sử", "action.remove": "Xóa làn + worktree", "action.reset": "Đặt lại worktree", @@ -16,6 +16,7 @@ "actions.syncCheck": "Kiểm tra đồng bộ dev", "actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}", "actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}", + "actions.tools": "Công cụ", "add": "Thêm lane", "addLane": "Tạo lane từ một thư mục làm việc", "addLaneBaseLabel": "Nhánh để tạo nhánh mới", diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index c36c1f5a..b7ac7ba1 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -48,9 +48,10 @@ import type { Lane, LaneFeature, LaneCounts, ProofFeature, Session, WSMessage } import { eventBus } from "../lib/eventBus"; import { LaneConsolePane } from "../components/run/LaneConsolePane"; import PipelineMap from "../components/lanes/PipelineMap"; -import LaneCard from "../components/lanes/LaneCard"; +import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard"; import LaneStripCard from "../components/lanes/LaneStripCard"; import { AddLaneModal } from "../components/lanes/AddLaneModal"; +import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage"; import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage"; @@ -182,6 +183,7 @@ export function Workspace() { >([]); const [viewedFeature, setViewedFeature] = useState(null); const [proofFeatures, setProofFeatures] = useState([]); + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); // Run state kept at page level: shared across every pane, or drives the // lane strip itself rather than any one pane's form. @@ -511,14 +513,17 @@ export function Workspace() { )}
- +
+ {currentLane && } + +
{/* Lane strip + detail stack vertically: the strip is a horizontally @@ -549,13 +554,24 @@ export function Workspace() { className="flex min-h-0 min-w-0 flex-1 flex-col card p-4" >
- - {tLanes("laneHeader", { - id: currentLane.id, - title: currentLane.title || currentLane.cwd, - pipeline: currentLane.pipeline_name, - })} - +
+ + + {currentLane.liveness === "dead" + ? tLanes("statusDead") + : tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })} + + + {tLanes("laneHeader", { + id: currentLane.id, + title: currentLane.title || currentLane.cwd, + pipeline: currentLane.pipeline_name, + })} + + + {since(currentLane.stage_seconds)} + +