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