b7f3735b29
- 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.
658 lines
24 KiB
TypeScript
658 lines
24 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|