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:
@@ -459,20 +459,18 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(screen.getByTestId("workspace-session-s-y")).toBeInTheDocument();
|
expect(screen.getByTestId("workspace-session-s-y")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
|
it("offers Reset only on a managed workspace", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree();
|
renderTree();
|
||||||
await user.click(await screen.findByTestId("workspace-menu-1"));
|
await user.click(await screen.findByTestId("workspace-menu-1"));
|
||||||
let menu = screen.getByTestId("workspace-row-menu-1");
|
let menu = screen.getByTestId("workspace-row-menu-1");
|
||||||
expect(within(menu).queryByText("Reset worktree…")).toBeNull();
|
expect(within(menu).queryByText("Reset worktree…")).toBeNull();
|
||||||
expect(within(menu).getByText("Create/update readme file")).toBeInTheDocument();
|
|
||||||
expect(within(menu).getByText("Delete workspace…")).toBeInTheDocument();
|
expect(within(menu).getByText("Delete workspace…")).toBeInTheDocument();
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
await user.click(screen.getByTestId("workspace-menu-3"));
|
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||||
menu = screen.getByTestId("workspace-row-menu-3");
|
menu = screen.getByTestId("workspace-row-menu-3");
|
||||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||||
expect(within(menu).queryByText("Create/update readme file")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
* lane-detail section, whose header above it already shows them. Neither is
|
* lane-detail section, whose header above it already shows them. Neither is
|
||||||
* progress: the pipeline map directly below this card draws the same number
|
* 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
|
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||||
* second telling of it. The repo
|
* second telling of it. The working copy — branch, uncommitted counts, the
|
||||||
* 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
|
|
||||||
* git action bar — is not here either: it heads the Changes panel
|
* git action bar — is not here either: it heads the Changes panel
|
||||||
* (`LaneGitHeader`), beside the diff it describes.
|
* (`LaneGitHeader`), beside the diff it describes.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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`;
|
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({
|
export default function LaneCard({
|
||||||
lane,
|
lane,
|
||||||
onAction,
|
onAction,
|
||||||
|
|||||||
@@ -894,7 +894,7 @@ function HomeBody({
|
|||||||
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
|
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"
|
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>
|
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -909,7 +909,10 @@ function HomeBody({
|
|||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={t("home.memory")}
|
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) => (
|
{memory.map((f, i) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
* @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
|
* 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`),
|
* 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
|
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
|
||||||
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
|
|
||||||
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* 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
|
* 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,
|
* 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")}
|
{live ? t("menu.stop") : t("menu.start")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</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={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||||
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
|
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
|
|||||||
@@ -589,9 +589,8 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — create/update readme", () => {
|
describe("AddLaneModal — create/update readme", () => {
|
||||||
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
|
it("is not offered here", async () => {
|
||||||
// Creating a lane and writing its docs are separate decisions, and the
|
// Creating a lane and writing its docs are separate decisions.
|
||||||
// second one is worth re-running later; see LaneToolsMenu.
|
|
||||||
renderModal();
|
renderModal();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
import LaneCard from "../LaneCard";
|
||||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
@@ -24,7 +24,6 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
down: vi.fn(),
|
down: vi.fn(),
|
||||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
syncBaseCheck: vi.fn(),
|
|
||||||
},
|
},
|
||||||
locks: {
|
locks: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -294,82 +293,3 @@ describe("LaneCard — named locks", () => {
|
|||||||
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* worktree is still the workspace home's prompt box) — plus a link to
|
* worktree is still the workspace home's prompt box) — plus a link to
|
||||||
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
|
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
|
||||||
* bar is the console's only header: Home (the workspace's home) at its left
|
* bar is the console's only header: Home (the workspace's home) at its left
|
||||||
* end, `actions` (the workspace's Tools menu) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
* end, `actions` (the single pane's split menus) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
|
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
|
||||||
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
|
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
|
||||||
@@ -29,6 +29,31 @@ import { freeSlot } from "../lanes/WorkspaceHome";
|
|||||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
|
||||||
|
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||||
|
|
||||||
|
/** The tools a new tab or split offers: the workspace's own whatever its
|
||||||
|
* state, then every installed, enabled one. */
|
||||||
|
export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
lane.tool,
|
||||||
|
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The start body of a bare tool on its default model, no prompt. Only a
|
||||||
|
* different tool is named, as the home does: the workspace's own keeps
|
||||||
|
* starting even once disabled. */
|
||||||
|
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||||
|
const model = aiTools.byId(id)?.defaultModel;
|
||||||
|
return {
|
||||||
|
prompt: "",
|
||||||
|
...(id !== lane.tool ? { tool: id } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ConsoleTabs({
|
export function ConsoleTabs({
|
||||||
lane,
|
lane,
|
||||||
tabs,
|
tabs,
|
||||||
@@ -52,7 +77,7 @@ export function ConsoleTabs({
|
|||||||
onClosed: (runId: string) => void;
|
onClosed: (runId: string) => void;
|
||||||
/** The workspace's home (its prompt box, Recents, context). */
|
/** The workspace's home (its prompt box, Recents, context). */
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
/** Right end of the bar (the workspace's Tools menu). */
|
/** Right end of the bar (the single pane's split menus). */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
@@ -79,13 +104,7 @@ export function ConsoleTabs({
|
|||||||
|
|
||||||
const slot = freeSlot(lane.id, tabs);
|
const slot = freeSlot(lane.id, tabs);
|
||||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||||
// The workspace's own tool whatever its state, then every installed, enabled one.
|
const toolIds = newTabTools(lane, aiTools);
|
||||||
const toolIds = [
|
|
||||||
...new Set([
|
|
||||||
lane.tool,
|
|
||||||
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const open = async (body: Record<string, unknown>, split = false) => {
|
const open = async (body: Record<string, unknown>, split = false) => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -217,33 +236,19 @@ export function ConsoleTabs({
|
|||||||
{t("tabs.newTerminal")}
|
{t("tabs.newTerminal")}
|
||||||
</button>
|
</button>
|
||||||
<div className="my-1.5 border-t border-border" />
|
<div className="my-1.5 border-t border-border" />
|
||||||
{toolIds.map((id) => {
|
{toolIds.map((id) => (
|
||||||
const model = aiTools.byId(id)?.defaultModel;
|
<button
|
||||||
return (
|
key={id}
|
||||||
<button
|
type="button"
|
||||||
key={id}
|
role="menuitem"
|
||||||
type="button"
|
data-testid={`console-tab-new-${id}`}
|
||||||
role="menuitem"
|
className={item}
|
||||||
data-testid={`console-tab-new-${id}`}
|
onClick={(e) => void open(newTabBody(id, lane, aiTools), e.ctrlKey || e.metaKey)}
|
||||||
className={item}
|
>
|
||||||
onClick={(e) =>
|
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
|
||||||
void open(
|
{aiTools.label(id)}
|
||||||
{
|
</button>
|
||||||
prompt: "",
|
))}
|
||||||
// Only a different tool is named, as the home does: the
|
|
||||||
// workspace's own keeps starting even once disabled.
|
|
||||||
...(id !== lane.tool ? { tool: id } : {}),
|
|
||||||
...(model ? { model } : {}),
|
|
||||||
},
|
|
||||||
e.ctrlKey || e.metaKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
|
|
||||||
{aiTools.label(id)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="my-1.5 border-t border-border" />
|
<div className="my-1.5 border-t border-border" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -17,16 +17,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Check, ChevronDown, Columns2, GripVertical, Rows2, SquareTerminal, X } from "lucide-react";
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Columns2,
|
|
||||||
GripVertical,
|
|
||||||
Plus,
|
|
||||||
Rows2,
|
|
||||||
SquareTerminal,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import {
|
import {
|
||||||
@@ -174,7 +165,7 @@ const ITEM =
|
|||||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||||
|
|
||||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
/** A pane's split-right / split-down menus: move a hidden run beside
|
||||||
* `slot`, or start a new terminal there. In a pane header when the view is
|
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
|
||||||
* split, beside Tools in the tab bar while one pane is shown. */
|
* split, beside Tools in the tab bar while one pane is shown. */
|
||||||
export function SplitMenus({
|
export function SplitMenus({
|
||||||
slot,
|
slot,
|
||||||
@@ -183,6 +174,8 @@ export function SplitMenus({
|
|||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
canStart,
|
canStart,
|
||||||
|
tools,
|
||||||
|
toolLabel,
|
||||||
onNewSplit,
|
onNewSplit,
|
||||||
}: {
|
}: {
|
||||||
slot: number;
|
slot: number;
|
||||||
@@ -192,7 +185,11 @@ export function SplitMenus({
|
|||||||
label: (r: RunHandle) => string;
|
label: (r: RunHandle) => string;
|
||||||
onChange: LayoutUpdate;
|
onChange: LayoutUpdate;
|
||||||
canStart: boolean;
|
canStart: boolean;
|
||||||
onNewSplit: (target: number, edge: Edge) => void;
|
/** AI tool ids a new split can start, beside the terminal. */
|
||||||
|
tools: string[];
|
||||||
|
toolLabel: (id: string) => string;
|
||||||
|
/** `tool`: an AI tool id; none — a terminal. */
|
||||||
|
onNewSplit: (target: number, edge: Edge, tool?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const full = shown.length >= MAX_PANES;
|
const full = shown.length >= MAX_PANES;
|
||||||
@@ -246,9 +243,27 @@ export function SplitMenus({
|
|||||||
onNewSplit(slot, edge);
|
onNewSplit(slot, edge);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<SquareTerminal className="h-4 w-4" />
|
||||||
{t("panes.newTerminal")}
|
{t("panes.newTerminal")}
|
||||||
</button>
|
</button>
|
||||||
|
{tools.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-testid={`pane-split-new-${edge}-${slot}-${id}`}
|
||||||
|
disabled={!canStart}
|
||||||
|
title={canStart ? undefined : t("tabs.full")}
|
||||||
|
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onNewSplit(slot, edge, id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AiToolIcon tool={{ id, label: toolLabel(id) }} className="h-4 w-4 !p-0" />
|
||||||
|
{toolLabel(id)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PaneMenu>
|
</PaneMenu>
|
||||||
@@ -265,6 +280,8 @@ export function PaneGrid({
|
|||||||
renderPane,
|
renderPane,
|
||||||
onChange,
|
onChange,
|
||||||
canStart,
|
canStart,
|
||||||
|
tools,
|
||||||
|
toolLabel,
|
||||||
onNewSplit,
|
onNewSplit,
|
||||||
}: {
|
}: {
|
||||||
layout: PaneLayout;
|
layout: PaneLayout;
|
||||||
@@ -277,8 +294,11 @@ export function PaneGrid({
|
|||||||
onChange: LayoutUpdate;
|
onChange: LayoutUpdate;
|
||||||
/** A slot is free for a new terminal. */
|
/** A slot is free for a new terminal. */
|
||||||
canStart: boolean;
|
canStart: boolean;
|
||||||
/** Start a new terminal into a split of `target`. */
|
/** AI tool ids a new split can start, beside the terminal. */
|
||||||
onNewSplit: (target: number, edge: Edge) => void;
|
tools: string[];
|
||||||
|
toolLabel: (id: string) => string;
|
||||||
|
/** Start a new terminal (or AI tool) into a split of `target`. */
|
||||||
|
onNewSplit: (target: number, edge: Edge, tool?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -450,6 +470,8 @@ export function PaneGrid({
|
|||||||
label={label}
|
label={label}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
canStart={canStart}
|
canStart={canStart}
|
||||||
|
tools={tools}
|
||||||
|
toolLabel={toolLabel}
|
||||||
onNewSplit={onNewSplit}
|
onNewSplit={onNewSplit}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
"action.stop": "stop",
|
"action.stop": "stop",
|
||||||
"actionError": "Workspace action failed: {{message}}",
|
"actionError": "Workspace action failed: {{message}}",
|
||||||
"actionErrorUnknown": "Unknown error",
|
"actionErrorUnknown": "Unknown error",
|
||||||
"actions.busy": "…",
|
|
||||||
"actions.mcpSync": "Sync MCP",
|
|
||||||
"actions.mcpSyncResult": "Synced: {{servers}}",
|
|
||||||
"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",
|
|
||||||
"actions.writeDocs": "Create/update readme file",
|
|
||||||
"actions.writeDocsHint": "Opens this workspace's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
|
|
||||||
"actions.writeDocsResult": "Started — see this workspace's terminal.",
|
|
||||||
"add": "Add workspace",
|
"add": "Add workspace",
|
||||||
"addLane": "Create a workspace from a working directory",
|
"addLane": "Create a workspace from a working directory",
|
||||||
"addLaneBaseLabel": "Branch to fork from",
|
"addLaneBaseLabel": "Branch to fork from",
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
"action.stop": "Dừng",
|
"action.stop": "Dừng",
|
||||||
"actionError": "Thao tác workspace thất bại: {{message}}",
|
"actionError": "Thao tác workspace thất bại: {{message}}",
|
||||||
"actionErrorUnknown": "Lỗi không xác định",
|
"actionErrorUnknown": "Lỗi không xác định",
|
||||||
"actions.busy": "…",
|
|
||||||
"actions.mcpSync": "Đồng bộ MCP",
|
|
||||||
"actions.mcpSyncResult": "Đã đồng bộ: {{servers}}",
|
|
||||||
"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ụ",
|
|
||||||
"actions.writeDocs": "Tạo/cập nhật file readme",
|
|
||||||
"actions.writeDocsHint": "Mở terminal của workspace này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
|
|
||||||
"actions.writeDocsResult": "Đã chạy — xem terminal của workspace này.",
|
|
||||||
"add": "Thêm workspace",
|
"add": "Thêm workspace",
|
||||||
"addLane": "Tạo workspace từ một thư mục làm việc",
|
"addLane": "Tạo workspace từ một thư mục làm việc",
|
||||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
||||||
|
|||||||
@@ -2917,16 +2917,6 @@ export const api = {
|
|||||||
`/lanes/${id}/profile/init`,
|
`/lanes/${id}/profile/init`,
|
||||||
{ method: "POST", body: JSON.stringify({ force }) }
|
{ method: "POST", body: JSON.stringify({ force }) }
|
||||||
),
|
),
|
||||||
syncBaseCheck: (id: number, branch?: string) =>
|
|
||||||
request<{
|
|
||||||
code: number;
|
|
||||||
devDelta?: string[] | null;
|
|
||||||
overlap?: string[] | null;
|
|
||||||
collisions?: { file: string; collidesWith: string; suggestion: string }[];
|
|
||||||
}>(`/lanes/${id}/sync-base`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ mode: "check", branch }),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
// ──────────────────────────────── Locks API ────────────────────────────────
|
// ──────────────────────────────── Locks API ────────────────────────────────
|
||||||
/** Named locks across all lanes: used for serialization and gating.
|
/** Named locks across all lanes: used for serialization and gating.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
|
|||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
import { ConsoleTabs, newTabBody, newTabTools } from "../components/run/ConsoleTabs";
|
||||||
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
|
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
|
||||||
import {
|
import {
|
||||||
type Edge,
|
type Edge,
|
||||||
@@ -77,7 +77,7 @@ import { useAiTools } from "../hooks/useAiTools";
|
|||||||
import { freeSlot } from "../components/lanes/WorkspaceHome";
|
import { freeSlot } from "../components/lanes/WorkspaceHome";
|
||||||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
import LaneCard, { since, LaneToolsMenu } from "../components/lanes/LaneCard";
|
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
@@ -285,16 +285,19 @@ function ConsoleArea({
|
|||||||
else onNewSession?.();
|
else onNewSession?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// A split menu's "New terminal": a shell in the first free slot, placed on
|
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||||||
// that side of the pane — unless the workspace changed while it started.
|
// starts it) in the first free slot, placed on that side of the pane —
|
||||||
|
// unless the workspace changed while it started.
|
||||||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||||||
const newSplit = async (target: number, edge: Edge) => {
|
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
||||||
|
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||||||
if (!lane || free === null) return;
|
if (!lane || free === null) return;
|
||||||
const laneId = lane.id;
|
const laneId = lane.id;
|
||||||
const slot = free;
|
const slot = free;
|
||||||
setSplitError(null);
|
setSplitError(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.action(laneId, "start", { slot, shell: true });
|
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
|
||||||
|
const r = await api.lanes.action(laneId, "start", { slot, ...body });
|
||||||
onRunsChanged();
|
onRunsChanged();
|
||||||
if (!r.run_id || laneRef.current !== laneId) return;
|
if (!r.run_id || laneRef.current !== laneId) return;
|
||||||
pending.current.set(slot, activeRuns);
|
pending.current.set(slot, activeRuns);
|
||||||
@@ -327,11 +330,10 @@ function ConsoleArea({
|
|||||||
}}
|
}}
|
||||||
onClosed={(runId) => ended(slotOf(runId))}
|
onClosed={(runId) => ended(slotOf(runId))}
|
||||||
onHome={onNewSession}
|
onHome={onNewSession}
|
||||||
// Per-workspace actions (mcp sync, sync-base check, readme), and
|
// While one pane is shown, its split menus (a split pane has its own).
|
||||||
// while one pane is shown its split menus (a split pane has its own).
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
shown.length === 1 && shown[0] !== undefined ? (
|
||||||
{shown.length === 1 && shown[0] !== undefined && (
|
<div className="flex items-center gap-1">
|
||||||
<SplitMenus
|
<SplitMenus
|
||||||
slot={shown[0]}
|
slot={shown[0]}
|
||||||
shown={shown}
|
shown={shown}
|
||||||
@@ -340,11 +342,12 @@ function ConsoleArea({
|
|||||||
// A layout never saved is the one-pane `view`: split that.
|
// A layout never saved is the one-pane `view`: split that.
|
||||||
onChange={(fn) => change((l) => fn(l.root ? l : view))}
|
onChange={(fn) => change((l) => fn(l.root ? l : view))}
|
||||||
canStart={free !== null}
|
canStart={free !== null}
|
||||||
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
tools={splitTools}
|
||||||
|
toolLabel={aiTools.label}
|
||||||
|
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<LaneToolsMenu lane={lane} />
|
) : undefined
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -360,7 +363,9 @@ function ConsoleArea({
|
|||||||
label={label}
|
label={label}
|
||||||
onChange={change}
|
onChange={change}
|
||||||
canStart={free !== null}
|
canStart={free !== null}
|
||||||
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
tools={splitTools}
|
||||||
|
toolLabel={aiTools.label}
|
||||||
|
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||||
renderPane={(slot) => (
|
renderPane={(slot) => (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type LaneFixture = {
|
|||||||
detected_signal: string | null;
|
detected_signal: string | null;
|
||||||
run_id: string | null;
|
run_id: string | null;
|
||||||
active_feature_id?: number | null;
|
active_feature_id?: number | null;
|
||||||
|
tool?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let lanesToReturn: LaneFixture[] = [
|
let lanesToReturn: LaneFixture[] = [
|
||||||
@@ -806,6 +807,17 @@ describe("split panes", () => {
|
|||||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a split menu starts an AI tool, bare, in the first free slot", async () => {
|
||||||
|
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
|
||||||
|
withRuns([live(0), live(1)]);
|
||||||
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||||
|
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, prompt: "" });
|
||||||
|
});
|
||||||
|
|
||||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||||
withRuns([live(0), live(1)]);
|
withRuns([live(0), live(1)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
|||||||
+2
-2
@@ -392,7 +392,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||||
@@ -401,7 +401,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||||
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||||
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. The **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) sits at the right end of the tab bar. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. Tools sits on the workspace rather than in Settings because every one of its actions runs against one workspace's `cwd`. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command.
|
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub lanes mcp sync` and `agenthub lanes sync-base`, and a readme refresh is a skill typed into a session.
|
||||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
||||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||||
|
|||||||
Reference in New Issue
Block a user