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
+22 -10
View File
@@ -19,6 +19,8 @@
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
* asks through the themed confirm; the rows wrap in a narrow panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -37,6 +39,9 @@ import {
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
@@ -170,6 +175,8 @@ export function LaneGitBar({
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
if (action === "fetch" || action === "pull" || action === "push" || action === "publish")
toast.success(t(`gitBar.done.${action}`));
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "publish") {
@@ -227,9 +234,14 @@ export function LaneGitBar({
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("gitBar.merge"),
message: t("gitBar.mergeConfirm", { branch, current: git.branch }),
confirmLabel: t("gitBar.merge"),
destructive: false,
});
if (ok) void run("merge", { branch });
};
const heldBy = new Map(
@@ -238,7 +250,7 @@ export function LaneGitBar({
return (
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
@@ -285,7 +297,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
onClick={() => void merge(git.upstream as string)}
/>
)}
@@ -362,13 +374,13 @@ export function LaneGitBar({
</div>
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
@@ -396,7 +408,7 @@ export function LaneGitBar({
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
onClick={() => void merge(picked)}
/>
{onNewLane && (
<Btn
@@ -436,7 +448,7 @@ export function LaneGitBar({
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
@@ -462,7 +474,7 @@ export function LaneGitBar({
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.