/** * @file ConsoleTabs.tsx * @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, proxy 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, in the same order as a join, 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. Dropped on the MIDDLE * of another tab, a tab (or a pane header) joins that tab instead * (`joinGroup`: side by side, then under the right pane, then under the left * one, up to four panes). A right-click on a tab * opens the same actions as a menu (`ContextMenu`). * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react"; import { api } from "../../lib/api"; import type { RunHandle } from "../../lib/api"; import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { useAiTools } from "../../hooks/useAiTools"; import { AiToolIcon } from "../AiToolIcon"; import { freeSlot, startInFreeTab } from "../lanes/startSession"; import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid"; import { insertionIndex } from "../../lib/laneOrderStorage"; import { type PaneGroups, MAX_PANES, detachSlot, groupOf, joinGroup, leaves, reorderGroups, } from "../../lib/paneLayout"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; type AiToolsState = ReturnType; /** A tab's (and a pane header's) name: its session's own, else the first * line of the prompt it started on, else its tool and tab number. */ export function runName( r: RunHandle, session: Session | undefined, toolLabel: string, terminal: string ): string { if (r.tool === "shell") return terminal; const prompt = r.promptPreview?.trim().split("\n")[0]; return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`; } export type RunState = "awaiting" | "error" | "running"; /** What a live AI tab's dot says: waiting for you, failed, or at work. */ export function runState(session: Session | undefined): RunState { if (session && isSessionAwaitingInput(session)) return "awaiting"; if (session?.status === "error") return "error"; return "running"; } /** The state dot, with its meaning for a screen reader (colour alone says nothing). */ export function RunDot({ state }: { state: RunState }) { const { t } = useTranslation("lanes"); const colour = state === "awaiting" ? "bg-status-warning" : state === "error" ? "bg-status-danger" : "bg-status-success"; return ( <> {t(`tabs.state.${state}`)} ); } /** 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 } : {}), }; } /** 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: runs, groups, onGroupsChange, onOpen, onClosed, onHome, actions, sessions, onRenamed, }: { 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; /** A tab's session was renamed: re-read the sessions. */ onRenamed?: () => void; /** 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; /** The workspace's home (its prompt box, Recents, context). */ onHome?: () => void; /** Right end of the bar (the single pane's split menus). */ actions?: ReactNode; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const aiTools = useAiTools(); const [menuOpen, setMenuOpen] = useState(false); // The tab being renamed (its run id) and the draft name. const [renaming, setRenaming] = useState(null); const [draft, setDraft] = useState(""); const tabRefs = useRef(new Map()); const menuRef = useRef(null); const tabCtx = useContextMenu(); // A group whose runs the list has not caught up with yet is not drawn. const tabs = useMemo( () => 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(null); const [dropAt, setDropAt] = useState(null); // The drawn tab a drop would join (the pointer on its middle half), and the // slots a drag from this bar carries — unknown for a pane header (one slot). const [joinAt, setJoinAt] = useState(null); const dragged = useRef([]); 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("[data-tab-index]") ?? [])]; return insertionIndex( els.map((el) => { const b = el.getBoundingClientRect(); return b.left + b.width / 2; }), x ); }; const joinIndexAt = (x: number) => { const els = [...(listRef.current?.querySelectorAll("[data-tab-index]") ?? [])]; const i = els.findIndex((el) => { const b = el.getBoundingClientRect(); return x >= b.left + b.width / 4 && x <= b.right - b.width / 4; }); const tab = tabs[i]; if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null; return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null; }; const endDrag = () => { dragged.current = []; setDropAt(null); setJoinAt(null); }; const dragHandlers = { onDragOver: (e: DragEvent) => { if (!isBarDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const join = joinIndexAt(e.clientX); const at = join === null ? dropIndexAt(e.clientX) : null; setJoinAt((d) => (d === join ? d : join)); setDropAt((d) => (d === at ? d : at)); }, onDragLeave: (e: DragEvent) => { if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; setDropAt(null); setJoinAt(null); }, onDragEnd: endDrag, onDrop: (e: DragEvent) => { const join = joinIndexAt(e.clientX); endDrag(); if (!isBarDrag(e)) return; e.preventDefault(); const tab = e.dataTransfer.getData(TAB_DRAG_TYPE); const raw = e.dataTransfer.getData(PANE_DRAG_TYPE); const target = join === null ? undefined : tabs[join]; // A pane header dropped on its OWN tab is a detach, not a join: a pane // drag carries no slots during dragover, so joinIndexAt cannot skip it. const ownTab = tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index; if (target && !ownTab) { onGroupsChange((g) => joinGroup( g, tab !== "" ? leaves(g.groups[Number(tab)]?.root ?? null) : raw !== "" ? [Number(raw)] : [], target.index ) ); return; } // The drawn position, as an index into the groups. const drawn = dropIndexAt(e.clientX); const at = tabs[drawn]?.index ?? groups.groups.length; if (tab !== "") { onGroupsChange((g) => reorderGroups(g, Number(tab), at)); return; } 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; const onDown = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false); document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [menuOpen]); 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, split = false) => { setMenuOpen(false); if (slot === null) return; try { onOpen(await startInFreeTab(lane.id, body), split); } catch (err) { toast.error(err); } }; // 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 ( tab.runs.some((r) => r.tool !== "shell") && !(await confirmDialog({ 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; for (const r of tab.runs) { try { await api.run.kill(r.id); onClosed(r.id); } catch (err) { toast.error(err); } } }; const startRename = (r: RunHandle) => { if (!r.sessionId || r.tool === "shell") return; setDraft(sessionOf(r)?.name ?? ""); setRenaming(r.id); }; const saveRename = async (r: RunHandle) => { setRenaming(null); const name = draft.trim(); if (!r.sessionId || !name || name === sessionOf(r)?.name) return; try { await api.sessions.patch(r.sessionId, { name }); onRenamed?.(); } catch (err) { toast.error(err); } }; // APG tabs: one tab stop; arrows, Home and End move between the tabs. const onTabKey = (e: ReactKeyboardEvent, i: number) => { const last = tabs.length - 1; const to = e.key === "ArrowRight" ? i === last ? 0 : i + 1 : e.key === "ArrowLeft" ? i === 0 ? last : i - 1 : e.key === "Home" ? 0 : e.key === "End" ? last : null; if (to === null || !tabs[to]) return; e.preventDefault(); tabRefs.current.get(tabs[to].key)?.focus(); }; 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: (tab: Tab) => unknown) => () => { const tab = tabCtx.menu?.item; tabCtx.close(); 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 ( // The whole bar takes a tab's drop: past the last tab is the end.
{onHome && ( )}
{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 (
setDraft(e.target.value)} // Blur cancels, Enter saves — as a sidebar session row. onBlur={() => setRenaming(null)} onKeyDown={(e) => { if (e.key === "Escape") setRenaming(null); if (!isSubmitEnter(e)) return; e.preventDefault(); void saveRename(single); }} className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary" />
); } return (
{ dragged.current = tab.slots; 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" : "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary" } ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`} > {(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && ( )} {active && ( )}
); })}
{menuOpen && (
{toolIds.map((id) => ( ))}
)}
{actions &&
{actions}
} {tabCtx.menu && ctxTab && ( onOpen(tab.focus.id))}> {t("sessionMenu.open")} {ctxRun && ( onOpen(tab.focus.id, true))}> {t("sessionMenu.openSplit")} )} {ctxRun?.sessionId && ctxRun.tool !== "shell" && ( <> startRename(tab.focus))}> {t("menu.rename")} navigate(`/sessions/${tab.focus.sessionId}`))}> {t("sessionMenu.details")} void copyText(tab.focus.sessionId ?? ""))}> {t("sessionMenu.copyId")} )} void close(tab))}> {t("tabs.close")} )}
); }