feat(workspace): no double resume, server-picked tabs, terminal a11y

- A resume of a session a live pane already holds is refused (409
  ESESSIONLIVE with error.run_id) and the client opens that pane, pasting
  any typed message there; catalog tools refuse resumeSessionId
  (EUNSUPPORTED) instead of silently starting fresh.
- POST /api/lanes/:id/start takes slot: "auto": the server picks the free
  tab under the lane lock (components/lanes/startSession.ts on the client,
  kept out of WorkspaceHome so the sidebar does not pull react-markdown
  into the main bundle).
- Unsaved whole-file edits are guarded on every exit (Back, another file,
  tab switch, close, lane/session change, beforeunload).
- Closing an AI tab asks first; hiding a pane has its own icon. Tabs and
  pane headers show the session name, a state dot, rename by double-click.
- Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F /
  Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52
  clipboard, a Reconnecting overlay, debounced resizes, optional screen
  reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes.
- Changes panel: draggable splitter, wrapping git bar, history paging,
  disarming discard/undo/abort, inline diff comments pasted only into a
  visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show
  more in the sidebar and Recents; idle dots readable with sr-only text.
- Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+182 -30
View File
@@ -2,8 +2,14 @@
* @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
* `?run=`. A 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, and carries a state dot (waiting for you /
* error / running, with screen-reader text). A tab's X stops its session
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, 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, router or
* worktree is still the workspace home's prompt box) — plus a link to
@@ -16,21 +22,74 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
useEffect,
useRef,
useState,
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 type { Lane } from "../../lib/types";
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 } from "../lanes/WorkspaceHome";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid";
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[] {
@@ -63,10 +122,16 @@ export function ConsoleTabs({
onClosed,
onHome,
actions,
sessions,
onRenamed,
}: {
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** 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;
/** The focused pane's run. */
activeRunId: string | null;
/** Runs the other panes show. */
@@ -84,7 +149,10 @@ export function ConsoleTabs({
const navigate = useNavigate();
const aiTools = useAiTools();
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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<RunHandle>();
@@ -103,31 +171,82 @@ export function ConsoleTabs({
}, [menuOpen]);
const slot = freeSlot(lane.id, tabs);
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
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 toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => {
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, split);
onOpen(await startInFreeTab(lane.id, body), split);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
toast.error(err);
}
};
const close = async (runId: string) => {
setError(null);
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
const close = async (r: RunHandle) => {
if (
r.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(r) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(runId);
onClosed(runId);
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(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].id)?.focus();
};
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
const r = tabCtx.menu?.item;
@@ -153,9 +272,32 @@ export function ConsoleTabs({
</button>
)}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
{tabs.map((r) => {
{tabs.map((r, i) => {
const active = r.id === activeRunId;
const shown = !active && shownRunIds.includes(r.id);
if (renaming === r.id) {
return (
<div key={r.id} className="flex items-center px-2">
<input
data-testid={`console-tab-rename-${r.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(r);
}}
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={r.id}
@@ -182,18 +324,34 @@ export function ConsoleTabs({
type="button"
role="tab"
aria-selected={active}
tabIndex={r.id === tabStop ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(r.id, el);
else tabRefs.current.delete(r.id);
}}
onKeyDown={(e) => onTabKey(e, i)}
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
title={[r.model, r.promptPreview, t("tabs.splitHint")].filter(Boolean).join("\n")}
onDoubleClick={() => startRename(r)}
title={[
toolLabel(r),
r.model,
r.promptPreview,
t("tabs.splitHint"),
r.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<RunIcon run={r} label={label(r)} />
<RunIcon run={r} label={toolLabel(r)} />
<span className="truncate">{label(r)}</span>
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r.id)}
onClick={() => void close(r)}
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 ${
@@ -262,11 +420,6 @@ export function ConsoleTabs({
</div>
)}
</div>
{error && (
<span role="alert" className="ml-2 min-w-0 self-center truncate text-xs text-status-danger">
{error}
</span>
)}
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && (
<ContextMenu
@@ -279,20 +432,19 @@ export function ConsoleTabs({
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
{t("sessionMenu.openSplit")}
</MenuItem>
{tabCtx.menu.item.sessionId && (
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
<>
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
>
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}>
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>