feat(workspace): session view console as tabs, with New terminal

The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live
tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's
pane kept mounted and hidden with CSS. The + menu opens a new tab in the first
free slot: New terminal (a plain shell - shell: true on POST
/api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any
installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops
its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
This commit is contained in:
2026-10-02 18:09:31 +07:00
parent fadecaddd0
commit d9511ea446
21 changed files with 611 additions and 617 deletions
+214
View File
@@ -0,0 +1,214 @@
/**
* @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's X stops its session (`DELETE /api/run/:id`). The `+` menu
* opens a new tab in the first free slot — 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`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Plus, Settings2, SquareTerminal, X } from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot } from "../lanes/WorkspaceHome";
export function ConsoleTabs({
lane,
tabs,
activeRunId,
onOpen,
onClosed,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
activeRunId: string | null;
onOpen: (runId: string) => void;
/** A tab's session was stopped. */
onClosed: (runId: string) => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
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, tabs);
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
// The workspace's own tool whatever its state, then every installed, enabled one.
const toolIds = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const open = async (body: Record<string, unknown>) => {
setMenuOpen(false);
if (slot === null) return;
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
if (r.run_id) onOpen(r.run_id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const close = async (runId: string) => {
setError(null);
try {
await api.run.kill(runId);
onClosed(runId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const item =
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border">
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-end gap-0.5">
{tabs.map((r) => {
const active = r.id === activeRunId;
return (
<div
key={r.id}
className={`group flex min-w-0 max-w-[14rem] items-center rounded-t-md border border-b-0 ${
active
? "border-border bg-surface-2 text-fg-primary"
: "border-transparent text-fg-muted hover:bg-surface-2/60 hover:text-fg-secondary"
}`}
>
<button
type="button"
role="tab"
aria-selected={active}
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={() => onOpen(r.id)}
title={[r.model, r.promptPreview].filter(Boolean).join(" · ") || undefined}
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-2.5 pr-1 text-xs"
>
{r.tool === "shell" ? (
<SquareTerminal className="h-3.5 w-3.5 shrink-0" />
) : (
<AiToolIcon tool={{ id: r.tool, label: label(r) }} className="h-3.5 w-3.5 !p-0" />
)}
<span className="truncate">{label(r)}</span>
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r.id)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className="mr-1 shrink-0 rounded p-0.5 opacity-60 hover:bg-surface-4 hover:text-status-danger hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<div ref={menuRef} className="relative">
<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 p-1 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-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
>
<button
type="button"
role="menuitem"
className={item}
onClick={() => void open({ shell: true })}
>
<SquareTerminal className="h-4 w-4" />
{t("tabs.newTerminal")}
</button>
<div className="my-1 border-t border-border" />
{toolIds.map((id) => {
const model = aiTools.byId(id)?.defaultModel;
return (
<button
key={id}
type="button"
role="menuitem"
data-testid={`console-tab-new-${id}`}
className={item}
onClick={() =>
void open({
prompt: "",
// Only a different tool is named, as the home does: the
// workspace's own keeps starting even once disabled.
...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}),
})
}
>
<AiToolIcon tool={{ id, label: aiTools.label(id) }} className="h-4 w-4 !p-0" />
{aiTools.label(id)}
</button>
);
})}
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
className={item}
onClick={() => navigate("/settings/agents")}
>
<Settings2 className="h-4 w-4" />
{t("tabs.agentSettings")}
</button>
</div>
)}
</div>
{error && (
<span role="alert" className="ml-2 min-w-0 truncate text-xs text-status-danger">
{error}
</span>
)}
</div>
);
}