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
+48 -18
View File
@@ -8,16 +8,28 @@
* remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or close the pane (its session keeps running as a tab).
* right or down, or HIDE the pane (its session keeps running as a tab — so
* its icon is not the tab's stop X). A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys.
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
* saved and the terminals refit once, not on every pointer move.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, Columns2, GripVertical, Rows2, SquareTerminal, X } from "lucide-react";
import {
Check,
ChevronDown,
Columns2,
GripVertical,
Minimize2,
Rows2,
SquareTerminal,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
@@ -37,6 +49,7 @@ import {
swapInLayout,
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -283,8 +296,11 @@ export function PaneGrid({
tools,
toolLabel,
onNewSplit,
stateOf,
}: {
layout: PaneLayout;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
mounted: number[];
/** The workspace's live runs (its tabs). */
@@ -433,6 +449,7 @@ export function PaneGrid({
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
{run && <RunIcon run={run} label={label(run)} />}
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
{run && stateOf?.(run) && <RunDot state={stateOf(run)!} />}
<PaneMenu
testId={`pane-pick-${slot}`}
label={t("panes.pick")}
@@ -482,14 +499,14 @@ export function PaneGrid({
onClick={() => onChange((l) => closeInLayout(l, slot))}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
<Minimize2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5">
{renderPane(slot)}
</div>
{/* No padding here: the terminal paints its own gutter in its
palette, so a dark pane never sits in a light-page frame. */}
<div className="flex min-h-0 flex-1 flex-col">{renderPane(slot)}</div>
</div>
);
})}
@@ -550,23 +567,31 @@ export function PaneGrid({
);
}
/** A split's divider: a 1px line with a wider grab area. */
function Divider({
/** A split's divider: a 1px line with a wider grab area. Also the Changes
* panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */
export function Divider({
gridRef,
dir,
at,
box,
onRatio,
label,
}: {
gridRef: React.RefObject<HTMLDivElement>;
dir: SplitDir;
at: number;
box: Rect;
onRatio: (ratio: number) => void;
/** Accessible name; the panes' own by default. */
label?: string;
}) {
const { t } = useTranslation("lanes");
const [dragging, setDragging] = useState(false);
// The ratio under the pointer while dragging: only this line moves until
// release, when the layout takes it once.
const [live, setLive] = useState<number | null>(null);
const dragging = live !== null;
const row = dir === "row";
const pos = live ?? at;
const ratioAt = (e: React.PointerEvent) => {
const g = gridRef.current?.getBoundingClientRect();
if (!g || !g.width || !g.height) return null;
@@ -575,17 +600,22 @@ function Divider({
: ((e.clientY - g.top) / g.height - box.y) / box.h;
};
const style = row
? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
? { left: `${(box.x + box.w * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
: { top: `${(box.y + box.h * pos) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
const clamp = (r: number) => Math.min(0.9, Math.max(0.1, r));
const finish = () => {
if (live !== null && live !== at) onRatio(live);
setLive(null);
};
return (
<div
role="separator"
tabIndex={0}
aria-orientation={row ? "vertical" : "horizontal"}
aria-valuenow={Math.round(at * 100)}
aria-valuenow={Math.round(pos * 100)}
aria-valuemin={10}
aria-valuemax={90}
aria-label={t("panes.resize")}
aria-label={label ?? t("panes.resize")}
className={`group absolute z-10 flex justify-center outline-none ${
row
? "w-2 -translate-x-1/2 cursor-col-resize"
@@ -595,15 +625,15 @@ function Divider({
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
setLive(at);
}}
onPointerMove={(e) => {
if (!dragging) return;
const r = ratioAt(e);
if (r !== null) onRatio(r);
if (r !== null) setLive(clamp(r));
}}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onPointerUp={finish}
onPointerCancel={() => setLive(null)}
onKeyDown={(e) => {
const back = row ? "ArrowLeft" : "ArrowUp";
const fwd = row ? "ArrowRight" : "ArrowDown";