Files
AgentHub/client/src/components/run/ConsoleTabs.tsx
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

658 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<typeof useAiTools>;
/** 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 (
<>
<span
data-state={state}
title={t(`tabs.state.${state}`)}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${colour}`}
aria-hidden
/>
<span className="sr-only">{t(`tabs.state.${state}`)}</span>
</>
);
}
/** 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<string, Session>;
/** 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<string | null>(null);
const [draft, setDraft] = useState("");
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
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);
// 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<number | null>(null);
const dragged = useRef<number[]>([]);
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 joinIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[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<string, unknown>, 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.
<div
className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"
{...dragHandlers}
>
{onHome && (
<button
type="button"
data-testid="console-home"
onClick={onHome}
title={t("tabs.home")}
aria-label={t("tabs.home")}
className="flex w-10 shrink-0 items-center justify-center border-r border-border text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<House className="h-4 w-4" />
</button>
)}
<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={tab.key} className="flex items-center px-2">
<input
data-testid={`console-tab-rename-${single.slot ?? 0}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
maxLength={200}
onChange={(e) => 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"
/>
</div>
);
}
return (
<div
key={tab.key}
data-tab-index={i}
data-slots={tab.key}
draggable
onDragStart={(e) => {
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)) && (
<span
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={tab.key === tabStop ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(tab.key, el);
else tabRefs.current.delete(tab.key);
}}
onKeyDown={(e) => onTabKey(e, i)}
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"
>
<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-${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 ${
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
<div ref={menuRef} className="relative flex items-center px-1.5">
<button
type="button"
data-testid="console-tab-new"
disabled={slot === null}
onClick={() => setMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t("tabs.new")}
title={slot === null ? t("tabs.full") : t("tabs.new")}
className="rounded-md p-1.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<Plus className="h-4 w-4" />
</button>
{menuOpen && (
<div
role="menu"
data-testid="console-tab-menu"
className="absolute left-0 top-full z-50 mt-1 w-64 rounded-xl border border-border bg-surface-2 p-1.5 shadow-lg"
>
<button
type="button"
role="menuitem"
className={item}
onClick={(e) => void open({ shell: true }, e.ctrlKey || e.metaKey)}
>
<SquareTerminal className="h-4 w-4" />
{t("tabs.newTerminal")}
</button>
<div className="my-1.5 border-t border-border" />
{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"
role="menuitem"
className={`${item} !text-fg-muted`}
onClick={() => navigate("/settings/agents")}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
</button>
</div>
)}
</div>
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && ctxTab && (
<ContextMenu
x={tabCtx.menu.x}
y={tabCtx.menu.y}
onClose={tabCtx.close}
testId={`console-tab-menu-${ctxTab.key}`}
>
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id))}>
{t("sessionMenu.open")}
</MenuItem>
{ctxRun && (
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id, true))}>
{t("sessionMenu.openSplit")}
</MenuItem>
)}
{ctxRun?.sessionId && ctxRun.tool !== "shell" && (
<>
<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((tab) => void copyText(tab.focus.sessionId ?? ""))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>
)}
</div>
);
}