feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -1,31 +1,38 @@
|
||||
/**
|
||||
* @file ConsoleTabs.tsx
|
||||
* @description The Workspace session view's tab bar: one tab per live tmux
|
||||
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
|
||||
* `?run=`. A tab is named by its session (`sessions.name`, double-click or
|
||||
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started
|
||||
* on, else its tool and number, and carries a state dot (waiting for you /
|
||||
* error / running, with screen-reader text). A tab's X stops its session
|
||||
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be
|
||||
* mid-turn; a shell closes at once. Arrow keys, Home and End move between tabs
|
||||
* (one tab stop, APG). Errors are toasts. The `+` menu opens a new tab in the
|
||||
* free slot the server picks (`slot: "auto"`) — a plain shell
|
||||
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
|
||||
* tool started bare on its default model, no prompt (a prompt, router or
|
||||
* 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 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`).
|
||||
* @description The Workspace session view's tab bar. Every tab is a GROUP of
|
||||
* one to four split panes (`PaneGroups`, `lib/paneLayout.ts`), each pane a
|
||||
* live tmux slot of the workspace (0–9, `GET /api/run`); the active tab is the
|
||||
* one on screen. A one-pane tab is named by its session (`sessions.name`,
|
||||
* double-click or Rename to change it — `PATCH /api/sessions/:id`), else the
|
||||
* prompt it started on, else its tool and number; a combo tab shows every
|
||||
* pane's icon and name. A tab carries a state dot (waiting for you / error /
|
||||
* running — a combo shows the most urgent, with screen-reader text). A tab's
|
||||
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
|
||||
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, router or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions 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 single pane's split menus)
|
||||
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
|
||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||
* bar takes that pane out into a tab of its own there. A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -42,7 +49,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import {
|
||||
type PaneGroups,
|
||||
detachSlot,
|
||||
groupOf,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
@@ -113,11 +128,27 @@ export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Drag data of a whole tab (its index), which only reorders the bar — a
|
||||
* one-pane tab drags as its pane instead (`PANE_DRAG_TYPE`). */
|
||||
const TAB_DRAG_TYPE = "application/x-agenthub-tab";
|
||||
|
||||
/** One tab: a group's index, slots and the runs the list knows of them. */
|
||||
interface Tab {
|
||||
index: number;
|
||||
key: string;
|
||||
slots: number[];
|
||||
runs: RunHandle[];
|
||||
/** The group's focused pane's run. */
|
||||
focus: RunHandle;
|
||||
}
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
tabs,
|
||||
activeRunId,
|
||||
shownRunIds = [],
|
||||
tabs: runs,
|
||||
groups,
|
||||
onGroupsChange,
|
||||
onOpen,
|
||||
onClosed,
|
||||
onHome,
|
||||
@@ -128,15 +159,16 @@ export function ConsoleTabs({
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** The tabs: groups of panes, the active one on screen. */
|
||||
groups: PaneGroups;
|
||||
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
||||
onGroupsChange: GroupsUpdate;
|
||||
/** The sessions the tabs hold, by id: their names and states. */
|
||||
sessions?: Map<string, Session>;
|
||||
/** A tab's session was renamed: re-read the sessions. */
|
||||
onRenamed?: () => void;
|
||||
/** The focused pane's run. */
|
||||
activeRunId: string | null;
|
||||
/** Runs the other panes show. */
|
||||
shownRunIds?: string[];
|
||||
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
|
||||
/** Shows the tab holding `runId`; `split` (Ctrl/Cmd+click) brings it into
|
||||
* the tab on screen instead. */
|
||||
onOpen: (runId: string, split?: boolean) => void;
|
||||
/** A tab's session was stopped. */
|
||||
onClosed: (runId: string) => void;
|
||||
@@ -154,7 +186,78 @@ export function ConsoleTabs({
|
||||
const [draft, setDraft] = useState("");
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<RunHandle>();
|
||||
const tabCtx = useContextMenu<Tab>();
|
||||
|
||||
// A group whose runs the list has not caught up with yet is not drawn.
|
||||
const tabs = useMemo<Tab[]>(
|
||||
() =>
|
||||
groups.groups.flatMap((l, index) => {
|
||||
const slots = leaves(l.root);
|
||||
const own = slots
|
||||
.map((s) => runs.find((r) => (r.slot ?? 0) === s))
|
||||
.filter((r): r is RunHandle => !!r);
|
||||
if (!own.length) return [];
|
||||
const focus = own.find((r) => (r.slot ?? 0) === l.focus) ?? (own[0] as RunHandle);
|
||||
return [{ index, key: slots.join("-"), slots, runs: own, focus }];
|
||||
}),
|
||||
[groups, runs]
|
||||
);
|
||||
|
||||
// A tab or a pane header dragged along the bar: `dropAt` is the insertion
|
||||
// point among the drawn tabs, from the pointer against their middles.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||
const isBarDrag = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||
};
|
||||
const dropIndexAt = (x: number) => {
|
||||
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||
return insertionIndex(
|
||||
els.map((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return b.left + b.width / 2;
|
||||
}),
|
||||
x
|
||||
);
|
||||
};
|
||||
const dragHandlers = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const at = dropIndexAt(e.clientX);
|
||||
setDropAt((d) => (d === at ? d : at));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
setDropAt(null);
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
// The drawn position, as an index into the groups.
|
||||
const drawn = dropIndexAt(e.clientX);
|
||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
if (tab !== "") {
|
||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||
return;
|
||||
}
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
if (raw === "") return;
|
||||
const slot = Number(raw);
|
||||
onGroupsChange((g) => {
|
||||
const from = groupOf(g, slot);
|
||||
if (from < 0) return g;
|
||||
// A pane of a combo leaves it; a one-pane tab just moves.
|
||||
return leaves(g.groups[from]?.root ?? null).length > 1
|
||||
? detachSlot(g, slot, at)
|
||||
: reorderGroups(g, from, at);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -170,11 +273,17 @@ export function ConsoleTabs({
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const slot = freeSlot(lane.id, tabs);
|
||||
const slot = freeSlot(lane.id, runs);
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||
const toolLabel = (r: RunHandle) =>
|
||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
||||
// A combo's dot is its most urgent AI pane's; a shell has none.
|
||||
const tabState = (tab: Tab): RunState | null => {
|
||||
const states = tab.runs.filter((r) => r.tool !== "shell").map((r) => runState(sessionOf(r)));
|
||||
return URGENCY.find((x) => states.includes(x)) ?? null;
|
||||
};
|
||||
const toolIds = newTabTools(lane, aiTools);
|
||||
|
||||
const open = async (body: Record<string, unknown>, split = false) => {
|
||||
@@ -187,23 +296,29 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
|
||||
const close = async (r: RunHandle) => {
|
||||
// An AI session may be mid-turn: stopping a tab holding one asks first. A
|
||||
// shell holds no agent.
|
||||
const close = async (tab: Tab) => {
|
||||
if (
|
||||
r.tool !== "shell" &&
|
||||
tab.runs.some((r) => r.tool !== "shell") &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: label(r) }),
|
||||
title:
|
||||
tab.runs.length > 1
|
||||
? t("tabs.stopGroupTitle", { count: tab.runs.length })
|
||||
: t("tabs.stopTitle", { name: label(tab.focus) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(r.id);
|
||||
onClosed(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
for (const r of tab.runs) {
|
||||
try {
|
||||
await api.run.kill(r.id);
|
||||
onClosed(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,22 +358,28 @@ export function ConsoleTabs({
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
e.preventDefault();
|
||||
tabRefs.current.get(tabs[to].id)?.focus();
|
||||
tabRefs.current.get(tabs[to].key)?.focus();
|
||||
};
|
||||
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
|
||||
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
|
||||
const r = tabCtx.menu?.item;
|
||||
const pickTab = (fn: (tab: Tab) => unknown) => () => {
|
||||
const tab = tabCtx.menu?.item;
|
||||
tabCtx.close();
|
||||
if (r) fn(r);
|
||||
if (tab) fn(tab);
|
||||
};
|
||||
const ctxTab = tabCtx.menu?.item;
|
||||
const ctxRun = ctxTab?.runs.length === 1 ? ctxTab.focus : null;
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
|
||||
// The whole bar takes a tab's drop: past the last tab is the end.
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"
|
||||
{...dragHandlers}
|
||||
>
|
||||
{onHome && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -271,15 +392,21 @@ export function ConsoleTabs({
|
||||
<House className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||
{tabs.map((r, i) => {
|
||||
const active = r.id === activeRunId;
|
||||
const shown = !active && shownRunIds.includes(r.id);
|
||||
if (renaming === r.id) {
|
||||
<div
|
||||
ref={listRef}
|
||||
role="tablist"
|
||||
aria-label={t("tabs.label")}
|
||||
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
|
||||
>
|
||||
{tabs.map((tab, i) => {
|
||||
const active = tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
return (
|
||||
<div key={r.id} className="flex items-center px-2">
|
||||
<div key={tab.key} className="flex items-center px-2">
|
||||
<input
|
||||
data-testid={`console-tab-rename-${r.slot ?? 0}`}
|
||||
data-testid={`console-tab-rename-${single.slot ?? 0}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
@@ -291,7 +418,7 @@ export function ConsoleTabs({
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (!isSubmitEnter(e)) return;
|
||||
e.preventDefault();
|
||||
void saveRename(r);
|
||||
void saveRename(single);
|
||||
}}
|
||||
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
|
||||
/>
|
||||
@@ -300,58 +427,79 @@ export function ConsoleTabs({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
key={tab.key}
|
||||
data-tab-index={i}
|
||||
data-slots={tab.key}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||
onContextMenu={(e) => tabCtx.open(e, r)}
|
||||
data-shown={shown ? "true" : undefined}
|
||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||
onDragStart={(e) => {
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => tabCtx.open(e, tab)}
|
||||
className={`group relative flex min-w-0 shrink-0 items-center rounded-t-md ${
|
||||
single ? "max-w-[15rem]" : "max-w-[22rem]"
|
||||
} ${
|
||||
active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: shown
|
||||
? "text-fg-secondary hover:bg-surface-4"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{(active || shown) && (
|
||||
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||
<span
|
||||
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${
|
||||
active ? "bg-accent" : "bg-fg-muted/50"
|
||||
aria-hidden
|
||||
data-testid="console-tab-drop-line"
|
||||
className={`pointer-events-none absolute inset-y-1.5 z-10 w-0.5 rounded-full bg-accent-solid ${
|
||||
dropAt === i ? "-left-px" : "-right-px"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{active && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={r.id === tabStop ? 0 : -1}
|
||||
tabIndex={tab.key === tabStop ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(r.id, el);
|
||||
else tabRefs.current.delete(r.id);
|
||||
if (el) tabRefs.current.set(tab.key, el);
|
||||
else tabRefs.current.delete(tab.key);
|
||||
}}
|
||||
onKeyDown={(e) => onTabKey(e, i)}
|
||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
|
||||
onDoubleClick={() => startRename(r)}
|
||||
title={[
|
||||
toolLabel(r),
|
||||
r.model,
|
||||
r.promptPreview,
|
||||
t("tabs.splitHint"),
|
||||
r.sessionId ? t("tabs.renameHint") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
data-testid={`console-tab-${tab.key}`}
|
||||
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
|
||||
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
|
||||
onDoubleClick={() => single && startRename(single)}
|
||||
title={
|
||||
single
|
||||
? [
|
||||
toolLabel(single),
|
||||
single.model,
|
||||
single.promptPreview,
|
||||
t("tabs.splitHint"),
|
||||
single.sessionId ? t("tabs.renameHint") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
|
||||
}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<RunIcon run={r} label={toolLabel(r)} />
|
||||
<span className="truncate">{label(r)}</span>
|
||||
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{tab.runs.map((r) => (
|
||||
<RunIcon key={r.id} run={r} label={toolLabel(r)} />
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${r.slot ?? 0}`}
|
||||
onClick={() => void close(r)}
|
||||
data-testid={`console-tab-close-${tab.key}`}
|
||||
onClick={() => void close(tab)}
|
||||
aria-label={t("tabs.close")}
|
||||
title={t("tabs.close")}
|
||||
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
|
||||
@@ -421,30 +569,36 @@ export function ConsoleTabs({
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
||||
{tabCtx.menu && (
|
||||
{tabCtx.menu && ctxTab && (
|
||||
<ContextMenu
|
||||
x={tabCtx.menu.x}
|
||||
y={tabCtx.menu.y}
|
||||
onClose={tabCtx.close}
|
||||
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`}
|
||||
testId={`console-tab-menu-${ctxTab.key}`}
|
||||
>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id))}>
|
||||
{t("sessionMenu.open")}
|
||||
</MenuItem>
|
||||
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
|
||||
{ctxRun && (
|
||||
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id, true))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctxRun?.sessionId && ctxRun.tool !== "shell" && (
|
||||
<>
|
||||
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
|
||||
<MenuItem onSelect={pickTab((tab) => startRename(tab.focus))}>
|
||||
{t("menu.rename")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pickTab((tab) => navigate(`/sessions/${tab.focus.sessionId}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
|
||||
<MenuItem onSelect={pickTab((tab) => void copyText(tab.focus.sessionId ?? ""))}>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
|
||||
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* whose button goes back to the workspace home, where a session starts with a
|
||||
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
|
||||
* a bare CLI or a shell). No header of its own: the tab bar is the console's
|
||||
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the
|
||||
* header. The Workspace page keeps one per live session (slot 0–9) mounted, the
|
||||
* inactive ones hidden with CSS.
|
||||
*
|
||||
* The pane is bound to the lane it shows: switching `laneId` swaps it to the
|
||||
@@ -97,7 +97,7 @@ export function LaneConsolePane({
|
||||
// arrives — the request must land after that. Only this lane's own tabs.
|
||||
useEffect(() => {
|
||||
if (!attachRequest || laneId === null) return;
|
||||
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
|
||||
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||||
let live = true;
|
||||
autoAttachedForLane.current = laneId;
|
||||
api.run
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* remount it and drop what it drew. With more than one pane each gets a slim
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or HIDE the pane (its session keeps running as a tab — so
|
||||
* its icon is not the tab's stop X). A header names the pane's session and
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
|
||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
@@ -33,8 +35,9 @@ import {
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_LAYOUT,
|
||||
EMPTY_GROUPS,
|
||||
type Edge,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
type SplitDir,
|
||||
@@ -42,9 +45,9 @@ import {
|
||||
closeInLayout,
|
||||
layoutRects,
|
||||
leaves,
|
||||
loadLayout,
|
||||
loadGroups,
|
||||
moveInLayout,
|
||||
saveLayout,
|
||||
saveGroups,
|
||||
setRatio,
|
||||
swapInLayout,
|
||||
} from "../../lib/paneLayout";
|
||||
@@ -72,13 +75,15 @@ export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
|
||||
}
|
||||
|
||||
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
||||
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
||||
|
||||
/** A workspace's pane layout, loaded and saved per workspace. A layout update
|
||||
* that lands after a workspace switch never writes into the new one. */
|
||||
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
|
||||
/** A workspace's tabs (groups of split panes), loaded and saved per
|
||||
* workspace. An update that lands after a workspace switch never writes into
|
||||
* the new one. */
|
||||
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
laneId: id,
|
||||
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
|
||||
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
||||
});
|
||||
const [state, setState] = useState(() => load(laneId));
|
||||
let current = state;
|
||||
@@ -87,18 +92,18 @@ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate]
|
||||
setState(current);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
|
||||
if (state.laneId !== null) saveGroups(state.laneId, state.groups);
|
||||
}, [state]);
|
||||
const update = useCallback<LayoutUpdate>(
|
||||
const update = useCallback<GroupsUpdate>(
|
||||
(fn) =>
|
||||
setState((s) => {
|
||||
if (s.laneId !== laneId) return s;
|
||||
const layout = fn(s.layout);
|
||||
return layout === s.layout ? s : { ...s, layout };
|
||||
const groups = fn(s.groups);
|
||||
return groups === s.groups ? s : { ...s, groups };
|
||||
}),
|
||||
[laneId]
|
||||
);
|
||||
return [current.layout, update];
|
||||
return [current.groups, update];
|
||||
}
|
||||
|
||||
type Zone = Edge | "center";
|
||||
|
||||
Reference in New Issue
Block a user