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
+41 -36
View File
@@ -9,7 +9,7 @@
* 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
* 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
* 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`).
@@ -29,6 +29,31 @@ import { freeSlot } from "../lanes/WorkspaceHome";
import { RunIcon, startPaneDrag } from "./PaneGrid";
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({
lane,
tabs,
@@ -52,7 +77,7 @@ export function ConsoleTabs({
onClosed: (runId: string) => void;
/** The workspace's home (its prompt box, Recents, context). */
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;
}) {
const { t } = useTranslation("lanes");
@@ -79,13 +104,7 @@ export function ConsoleTabs({
const slot = freeSlot(lane.id, tabs);
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 = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => {
setMenuOpen(false);
@@ -217,33 +236,19 @@ export function ConsoleTabs({
{t("tabs.newTerminal")}
</button>
<div className="my-1.5 border-t border-border" />
{toolIds.map((id) => {
const model = aiTools.byId(id)?.defaultModel;
return (
<button
key={id}
type="button"
role="menuitem"
data-testid={`console-tab-new-${id}`}
className={item}
onClick={(e) =>
void open(
{
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>
);
})}
{toolIds.map((id) => (
<button
key={id}
type="button"
role="menuitem"
data-testid={`console-tab-new-${id}`}
className={item}
onClick={(e) => void open(newTabBody(id, lane, aiTools), 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" />
<button
type="button"
+37 -15
View File
@@ -17,16 +17,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
Columns2,
GripVertical,
Plus,
Rows2,
SquareTerminal,
X,
} from "lucide-react";
import { Check, ChevronDown, Columns2, GripVertical, Rows2, SquareTerminal, X } from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
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";
/** 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. */
export function SplitMenus({
slot,
@@ -183,6 +174,8 @@ export function SplitMenus({
label,
onChange,
canStart,
tools,
toolLabel,
onNewSplit,
}: {
slot: number;
@@ -192,7 +185,11 @@ export function SplitMenus({
label: (r: RunHandle) => string;
onChange: LayoutUpdate;
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 full = shown.length >= MAX_PANES;
@@ -246,9 +243,27 @@ export function SplitMenus({
onNewSplit(slot, edge);
}}
>
<Plus className="h-4 w-4" />
<SquareTerminal className="h-4 w-4" />
{t("panes.newTerminal")}
</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>
@@ -265,6 +280,8 @@ export function PaneGrid({
renderPane,
onChange,
canStart,
tools,
toolLabel,
onNewSplit,
}: {
layout: PaneLayout;
@@ -277,8 +294,11 @@ export function PaneGrid({
onChange: LayoutUpdate;
/** A slot is free for a new terminal. */
canStart: boolean;
/** Start a new terminal into a split of `target`. */
onNewSplit: (target: number, edge: Edge) => void;
/** AI tool ids a new split can start, beside the terminal. */
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 gridRef = useRef<HTMLDivElement>(null);
@@ -450,6 +470,8 @@ export function PaneGrid({
label={label}
onChange={onChange}
canStart={canStart}
tools={tools}
toolLabel={toolLabel}
onNewSplit={onNewSplit}
/>
<button