feat(workspace): split menus start AI tools; drop the Tools menu

A pane's split right/down menus offered only New terminal. They now list
the same AI tools as the tab bar's + menu (newTabTools/newTabBody,
shared from ConsoleTabs), started bare in the first free slot on that
side of the pane.

The tab bar's Tools menu is gone: its readme run hit 409 ERUNLIVE
whenever slot 0 was live (always, in the session view) and depended on
a personal skill; MCP sync and the dev-sync check served the dormant
ship-feature-lane pipeline. The sidebar row menu's readme item goes too.
The routes stay, reached by `agenthub lanes mcp sync` and
`agenthub lanes sync-base`.

Also: the home's Memory header and file tabs no longer shrink (a long
file squeezed the tab row and clipped it).
This commit is contained in:
2026-10-05 10:55:10 +07:00
parent 151dc8a480
commit 138e706dd1
15 changed files with 126 additions and 353 deletions
+1 -144
View File
@@ -9,12 +9,7 @@
* lane-detail section, whose header above it already shows them. Neither is
* progress: the pipeline map directly below this card draws the same number
* as named, coloured nodes, so a bar and a percent beside it were a worse
* second telling of it. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
* install / MCP sync / dev-sync check / create-update readme) 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. The working copy — branch, uncommitted counts, the
* second telling of it. The working copy — branch, uncommitted counts, the
* git action bar — is not here either: it heads the Changes panel
* (`LaneGitHeader`), beside the diff it describes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -116,144 +111,6 @@ export function since(sec: number | null): string {
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
/**
* Repo tooling (MCP sync / dev-sync check, and the
* create/update-readme run, which moved here from the Add-lane modal) 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 [toolsMenuOpen, setToolsMenuOpen] = useState(false);
const [laneActionBusy, setLaneActionBusy] = useState<"mcp" | "sync-check" | "docs" | null>(null);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const runtime = useLaneRuntime(lane.id, 0);
const runLaneAction = async (which: "mcp" | "sync-check" | "docs", fn: () => Promise<string>) => {
setLaneActionBusy(which);
setLaneActionResult(null);
try {
setLaneActionResult(await fn());
} catch (err) {
setLaneActionResult(err instanceof Error ? err.message : String(err));
} finally {
setLaneActionBusy(null);
}
};
// A skill only exists inside a CLI session, so there is nothing server-side
// to call: this opens the lane's own terminal run with the slash command as
// its first message. Claude-only — codex has no slash commands.
const handleWriteDocs = () =>
runLaneAction("docs", async () => {
await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: "/sg-all:sg-md-writing",
});
return t("actions.writeDocsResult");
});
const handleMcpSync = () =>
runLaneAction("mcp", async () => {
const result = await api.lanes.mcpSync(lane.id);
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
});
const handleSyncCheck = () =>
runLaneAction("sync-check", async () => {
const result = await api.lanes.syncBaseCheck(lane.id);
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
const c = result.collisions[0]!;
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
}
return t("actions.syncCheckClean", {
count: result.devDelta?.length ?? 0,
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
});
});
if (!runtime?.available) return null;
return (
<div className="relative">
<button
type="button"
data-testid="lane-tools-menu"
aria-haspopup="menu"
aria-expanded={toolsMenuOpen}
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen((v) => !v);
}}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary disabled:opacity-50"
>
{laneActionBusy ? t("actions.busy") : t("actions.tools")}
</button>
{toolsMenuOpen && (
<div
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"
>
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
data-testid="lane-write-docs"
title={t("actions.writeDocsHint")}
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleWriteDocs();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.writeDocs")}
</button>
)}
<button
type="button"
role="menuitem"
data-testid="lane-mcp-sync"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleMcpSync();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.mcpSync")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-sync-check"
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleSyncCheck();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.syncCheck")}
</button>
</div>
)}
{laneActionResult && (
<p
data-testid="lane-action-result"
className="absolute right-0 top-full mt-1 w-max max-w-xs truncate rounded border border-border-light bg-surface-0 px-2 py-1 text-[11px] text-fg-secondary shadow-lg shadow-black/40"
>
{laneActionResult}
</p>
)}
</div>
);
}
export default function LaneCard({
lane,
onAction,
@@ -894,7 +894,7 @@ function HomeBody({
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex items-center justify-between">
<div className="mb-3 flex shrink-0 items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
@@ -909,7 +909,10 @@ function HomeBody({
<div
role="tablist"
aria-label={t("home.memory")}
className="mb-3 flex gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
// shrink-0: a horizontal scroller's min-height is 0, so in this
// height-capped column a long file squeezed the row and clipped
// the tabs. The panel below is the one part that scrolls.
className="mb-3 flex shrink-0 gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
>
{memory.map((f, i) => (
<button
@@ -2,8 +2,7 @@
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* Rename (the row's inline field), Create/update readme (claude only, the same run
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
@@ -59,23 +58,6 @@ export function WorkspaceRowMenu({
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
{lane.tool === "claude" && (
<MenuItem
onSelect={() =>
void run(
() =>
api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: "/sg-all:sg-md-writing",
}),
"started"
)
}
>
{t("actions.writeDocs")}
</MenuItem>
)}
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
{t("menu.copyPath")}
@@ -589,9 +589,8 @@ describe("AddLaneModal — folder browse", () => {
});
describe("AddLaneModal — create/update readme", () => {
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
// Creating a lane and writing its docs are separate decisions, and the
// second one is worth re-running later; see LaneToolsMenu.
it("is not offered here", async () => {
// Creating a lane and writing its docs are separate decisions.
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
@@ -9,10 +9,10 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import LaneCard from "../LaneCard";
import type { Lane } from "../../../lib/types";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
@@ -24,7 +24,6 @@ vi.mock("../../../lib/api", () => ({
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
},
locks: {
list: vi.fn(),
@@ -294,82 +293,3 @@ describe("LaneCard — named locks", () => {
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
});
});
describe("LaneToolsMenu", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.agenthub/profile",
ports: {},
} as unknown as LaneRuntime);
});
it("clicking Sync MCP calls the API and shows the result", async () => {
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
servers: ["playwright"],
profilesSeeded: [],
});
render(<LaneToolsMenu lane={makeLane()} />);
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");
});
it("clicking Check dev sync reports a clean result", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 0,
devDelta: ["a.txt", "b.txt"],
overlap: [],
});
render(<LaneToolsMenu lane={makeLane()} />);
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");
});
it("clicking Check dev sync reports a migration collision", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 5,
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
});
render(<LaneToolsMenu lane={makeLane()} />);
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("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
// Moved here from the Add-lane modal: a skill only runs inside a CLI
// session, and writing the docs is worth re-running long after creation.
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-write-docs"));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 1,
cwd: "/work/demo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
});
it("renders nothing when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});