Files
AgentHub/client/src/components/run/ConsoleTabs.tsx
T
nntrivi2001 0a1dcfeb7f feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix
- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00

801 lines
30 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 AND archives them (`endRuns`,
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
* asks first 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 pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
* opened on a workspace is one more tab at the bar's end (`settings`, kept
* until its X), shown instead of the panes while it is the active one.
* @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, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import { useSettingsHref } from "../../lib/settingsLink";
import type { RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { toast } from "../../lib/toast";
import { canSleep, endRuns } from "../../lib/stopSession";
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,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
MAX_PANES,
detachSlot,
groupOf,
joinGroup,
leaves,
reorderGroups,
slotOfKey,
} 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} #${slotOfKey(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"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
export function ConsoleTabs({
lane,
tabs: runs,
groups,
onGroupsChange,
onOpen,
onClosed,
onHome,
actions,
sessions,
onRenamed,
workspaceOf,
settings,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** Tabs from every workspace: the name of the workspace a run belongs to. */
workspaceOf?: (r: RunHandle) => string;
/** 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;
/** The Settings tab, while it is open: on screen (`active`), shown, closed. */
settings?: { active: boolean; onOpen: () => void; onClose: () => void } | null;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const settingsHref = useSettingsHref();
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;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
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;
stopSpring();
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) => {
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
};
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);
}
};
// Stop archives the tab's sessions; Sleep keeps them listed to resume
// (`endRuns` asks first for an AI pane, which may be mid-turn).
const close = async (tab: Tab, sleep = false) => {
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
};
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 keys = [...tabs.map((x) => x.key), ...(settings ? [SETTINGS_KEY] : [])];
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
const last = keys.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;
const key = to === null ? undefined : keys[to];
if (key === undefined) return;
e.preventDefault();
tabRefs.current.get(key)?.focus();
};
const tabStop = settings?.active
? SETTINGS_KEY
: (tabs.find((x) => x.index === groups.active)?.key ?? keys[0]);
// 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 = !settings?.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, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
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>
{tab.runs.some(canSleep) && (
<button
type="button"
data-testid={`console-tab-sleep-${tab.key}`}
onClick={() => void close(tab, true)}
aria-label={t("tabs.sleep")}
title={t("tabs.sleep")}
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
}`}
>
<Moon className="h-3.5 w-3.5" />
</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>
);
})}
{settings && (
<div
className={`group relative flex shrink-0 items-center rounded-t-md ${
settings.active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{settings.active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={settings.active}
tabIndex={tabStop === SETTINGS_KEY ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(SETTINGS_KEY, el);
else tabRefs.current.delete(SETTINGS_KEY);
}}
onKeyDown={(e) => onTabKey(e, keys.length - 1)}
data-testid="console-tab-settings"
onClick={settings.onOpen}
className="flex h-full items-center gap-2 pl-3 pr-1.5 text-sm"
>
<Settings className="h-4 w-4 shrink-0" />
{t("tabs.settings")}
</button>
<button
type="button"
data-testid="console-tab-settings-close"
onClick={settings.onClose}
aria-label={t("tabs.closeSettings")}
title={t("tabs.closeSettings")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
settings.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={() => {
setMenuOpen(false);
navigate(settingsHref("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 />
{ctxTab.runs.some(canSleep) && (
<MenuItem
testId="console-tab-menu-sleep"
onSelect={pickTab((tab) => void close(tab, true))}
>
{t("tabs.sleep")}
</MenuItem>
)}
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>
)}
</div>
);
}