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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user