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
+26 -40
View File
@@ -100,7 +100,8 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
@@ -128,7 +129,6 @@ export default function LaneCard({
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
@@ -203,9 +203,13 @@ export default function LaneCard({
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
info.listening ? "bg-status-success" : "border border-fg-muted"
}`}
aria-hidden
/>
<span className="sr-only">
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
</span>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
@@ -265,7 +269,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
className="truncate text-left text-accent hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
@@ -283,42 +287,23 @@ export default function LaneCard({
applies to managed lanes and is rare enough to stay behind ⋯. */}
{lane.kind === "managed" && (
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
<div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
</div>
{/* The shared menu shell: outside click, Escape and focus. */}
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={!!menuAt}
aria-label={t("moreActions")}
onClick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
⋯
</button>
</div>
)}
</div>
@@ -327,6 +312,7 @@ export default function LaneCard({
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
<MenuItem
danger
testId="lane-action-reset"
onSelect={() => {
setMenuAt(null);
setDestructiveAction("reset");