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
+41 -6
View File
@@ -18,12 +18,16 @@
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* A line's comment box floats just under the clicked line number (fixed,
* re-placed as the diff scrolls), not under the whole diff.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
@@ -265,7 +269,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
* opens the comment box for that line. */
const CommentLine = createContext<{
open: (line: number) => void;
open: (line: number, anchor?: HTMLElement) => void;
title: (line: number) => string;
} | null>(null);
@@ -276,7 +280,7 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
<button
type="button"
data-testid={`diff-comment-line-${n}`}
onClick={() => comment.open(n)}
onClick={(e) => comment.open(n, e.currentTarget)}
title={comment.title(n)}
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
>
@@ -741,18 +745,34 @@ export function LaneDiffView({
const [commentAt, setCommentAt] = useState<number | null>(null);
const [draft, setDraft] = useState("");
const [undelivered, setUndelivered] = useState(false);
// The clicked line number: the box floats just under it, where the eye is,
// instead of under the whole (possibly 2000-line) diff.
const anchorEl = useRef<HTMLElement | null>(null);
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => {
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
if (r) setAnchor({ top: r.bottom + 2, left: r.left });
}, []);
useEffect(() => {
if (commentAt == null) return;
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [commentAt, place]);
const openComment = useMemo(
() =>
onComment
? {
open: (line: number) => {
open: (line: number, el?: HTMLElement) => {
anchorEl.current = el ?? null;
setAnchor(null);
place();
setCommentAt(line);
setUndelivered(false);
},
title: (line: number) => t("changes.commentLine", { n: line }),
}
: null,
[onComment, t]
[onComment, t, place]
);
const sendComment = () => {
const text = draft.trim();
@@ -812,7 +832,10 @@ export function LaneDiffView({
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
onScroll={(e) => {
win.onScroll(e.currentTarget);
if (commentAt != null) place();
}}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
@@ -855,7 +878,19 @@ export function LaneDiffView({
{commentAt != null && (
<form
data-testid="diff-comment-box"
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
style={
anchor
? {
position: "fixed",
top: Math.min(anchor.top, window.innerHeight - 180),
left: anchor.left,
width: "min(28rem, calc(100vw - 2rem))",
}
: undefined
}
className={`z-30 flex flex-col gap-1 border-border bg-surface-2 p-2 ${
anchor ? "rounded-lg border shadow-lg" : "border-t"
}`}
onSubmit={(e) => {
e.preventDefault();
sendComment();