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:
@@ -32,7 +32,15 @@
|
||||
*
|
||||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
|
||||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
|
||||
* a toast says which tab took it, and a console not on screen takes nothing.
|
||||
*
|
||||
* An unsaved whole-file edit is never dropped silently: Back, another file,
|
||||
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
|
||||
* reloading the browser tab asks the browser's own question, and
|
||||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||||
* switching its tab, or moving it to another workspace or session. Two-click
|
||||
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -61,6 +69,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -428,14 +442,21 @@ function ChangeRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** History's page size, and the most the server reads (`listCommits`). */
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
onNewLane,
|
||||
full = false,
|
||||
tab = "changes",
|
||||
onDirtyChange,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||||
* so a different session is a fresh panel. */
|
||||
@@ -457,7 +478,17 @@ export function LaneChangesPanel({
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
const runId = pasteToLane(laneId, text);
|
||||
if (runId) {
|
||||
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
|
||||
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
|
||||
}
|
||||
return runId !== null;
|
||||
},
|
||||
[laneId, t]
|
||||
);
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
|
||||
}, [text]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
const [armedAbort, setArmedAbort] = useState(false);
|
||||
// Two-click arming that disarms by itself after 3 s: the armed path, the
|
||||
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
|
||||
const discardArm = useArmed();
|
||||
const confirmDiscard = discardArm.armed;
|
||||
const setConfirmDiscard = (path: string | null) =>
|
||||
path ? discardArm.arm(path) : discardArm.disarm();
|
||||
const abortArm = useArmed();
|
||||
const armedAbort = abortArm.armed !== null;
|
||||
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
|
||||
// A conflicted file opens in the conflict view; true = the plain editor.
|
||||
const [rawEdit, setRawEdit] = useState(false);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}`.
|
||||
const [armedHunk, setArmedHunk] = useState<string | null>(null);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
|
||||
const hunkArm = useArmed();
|
||||
const armedHunk = hunkArm.armed;
|
||||
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
|
||||
// Files folded shut in review mode. A change-set is read file by file, and
|
||||
// a file already read is in the way of the next one — so the set is what is
|
||||
// COLLAPSED, and a file arriving later is open by default.
|
||||
@@ -603,13 +643,19 @@ export function LaneChangesPanel({
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||||
// (up to the server's 200).
|
||||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: 50 })
|
||||
.gitLog(target, { limit: historyLimit })
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, laneId, reread]);
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the Files tab stays in
|
||||
// `browse` across the switch, so the mode alone never changes.
|
||||
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
|
||||
pendingScroll.current = null;
|
||||
}, [laneId]);
|
||||
// After the lane reset above: each tab opens on its own mode.
|
||||
// Leaving the open file this way (the Workspace asked first when it was
|
||||
// edited) drops it whole: a draft left behind would keep `dirty` true.
|
||||
useEffect(() => {
|
||||
if (!full) setMode(tab === "files" ? "browse" : "overview");
|
||||
if (full) return;
|
||||
setMode(tab === "files" ? "browse" : "overview");
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
setDraft(null);
|
||||
}, [tab, laneId, full]);
|
||||
const filesTab = tab === "files" && !full;
|
||||
|
||||
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
|
||||
pendingScroll.current = null;
|
||||
}, [mode, data]);
|
||||
|
||||
const show = (path: string) => {
|
||||
// Every way out of an open, edited file asks once; nothing else does.
|
||||
const dirtyRef = useRef(false);
|
||||
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
|
||||
|
||||
const showNow = (path: string) => {
|
||||
// Opening a file FROM a file (the review list is gone by then) must not
|
||||
// overwrite the list position that is still waiting to be restored.
|
||||
if (mode !== "file") {
|
||||
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
|
||||
setMode("file");
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const show = (path: string) => {
|
||||
void confirmDrop().then((ok) => ok && showNow(path));
|
||||
};
|
||||
|
||||
const goBackNow = () => {
|
||||
const back = returnTo.current;
|
||||
pendingScroll.current = back?.scroll ?? 0;
|
||||
returnTo.current = null;
|
||||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
setDraft(null);
|
||||
};
|
||||
const goBack = () => {
|
||||
void confirmDrop().then((ok) => ok && goBackNow());
|
||||
};
|
||||
|
||||
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
|
||||
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
};
|
||||
const copy = (text: string) => void navigator.clipboard.writeText(text);
|
||||
const copy = (text: string) => void copyText(text);
|
||||
// A menu item: close its menu, then act.
|
||||
const pick = (close: () => void, fn: () => void) => () => {
|
||||
close();
|
||||
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
|
||||
testId="lane-changes-tree-menu"
|
||||
>
|
||||
{!treeCtx.menu.item.dir && (
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
|
||||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
dirtyChangeRef.current = onDirtyChange;
|
||||
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
|
||||
// Unmounted with an edit open (the panel closed or moved on, after the
|
||||
// Workspace asked): nothing is dirty any more.
|
||||
useEffect(() => () => dirtyChangeRef.current?.(false), []);
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||||
@@ -1110,9 +1182,9 @@ export function LaneChangesPanel({
|
||||
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
const openWhole = (p: string) => {
|
||||
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
|
||||
show(p);
|
||||
const openWhole = async (p: string) => {
|
||||
if (!(await confirmDrop())) return;
|
||||
showNow(p);
|
||||
setWhole(true);
|
||||
};
|
||||
|
||||
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWhole(p)}
|
||||
onClick={() => void openWhole(p)}
|
||||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{p}
|
||||
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
|
||||
children={treeChildren}
|
||||
expanded={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
onOpenFile={openWhole}
|
||||
onOpenFile={(p) => void openWhole(p)}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
/>
|
||||
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
|
||||
onClick={() => setMode(m)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent text-white"
|
||||
? "bg-accent-solid text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={historyLoading}
|
||||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : mode === "browse" && !full ? (
|
||||
browsePane
|
||||
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
|
||||
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(null)}
|
||||
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.cancelEdit")}
|
||||
|
||||
Reference in New Issue
Block a user