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
+42 -4
View File
@@ -74,7 +74,7 @@
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
"changes.commentSend": "Paste into pane",
"changes.commentCancel": "Cancel",
"changes.commentNoPane": "No pane of this workspace is open — open its session first",
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
"changes.layout.split": "Side-by-side",
"changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Split down",
"panes.full": "4 panes is the most a workspace can show",
"panes.newTerminal": "New terminal",
"panes.close": "Close pane (its session keeps running as a tab)",
"panes.close": "Hide pane (its session keeps running as a tab)",
"panes.resize": "Resize panes",
"tabs.home": "Workspace home",
"sessionMenu.open": "Open",
@@ -401,9 +401,47 @@
"panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Close pane",
"panes.closeShort": "Hide pane",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
"changes.copySubject": "Copy commit message"
"changes.copySubject": "Copy commit message",
"replay.live": "This session is open in a tab — continue it there.",
"replay.noResume": "{{tool}} cannot resume a session by id, so this conversation is read-only here.",
"replay.newSession": "Start a new session",
"tabs.state.awaiting": "Waiting for you",
"tabs.state.error": "Error",
"tabs.state.running": "Running",
"tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
"tabs.stopConfirm": "Stop session",
"changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel",
"tree.archiveLiveTitle": "Archive a live session?",
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
"tree.state.awaiting": "waiting for you",
"tree.state.error": "error",
"tree.state.live": "live",
"tree.state.idle": "ended",
"tree.showMore_one": "Show {{count}} more",
"tree.showMore_other": "Show {{count}} more",
"home.contextPanel": "Workspace context",
"row.state.needsYou": "needs you",
"row.state.error": "error",
"row.state.running": "running",
"row.state.active": "active",
"row.state.idle": "idle",
"menu.stopTitle_one": "Stop the running session?",
"menu.stopTitle_other": "Stop {{count}} running sessions?",
"menu.stopBody": "Every tab of this workspace stops, even mid-turn. Its sessions can be resumed later from Recents.",
"gitBar.done.fetch": "Fetched",
"gitBar.done.pull": "Pulled",
"gitBar.done.push": "Pushed",
"gitBar.done.publish": "Published",
"runtime.listening": "listening",
"runtime.notListening": "not listening",
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
}
+12
View File
@@ -148,5 +148,17 @@
"imagesOnly": "Only images can be dropped here",
"remove": "Remove image",
"view": "View image"
},
"terminal": {
"ended": "[session ended, exit code {{code}}]",
"reconnecting": "Reconnecting… keystrokes are not reaching the session until the terminal is back.",
"keyboardHint": "Terminal. Press Ctrl+M to toggle whether Tab moves focus out of the terminal; Ctrl+Shift+F (Cmd+F on macOS) finds text.",
"tabMoves": "Tab moves focus — Ctrl+M to give Tab back to the terminal",
"selectHint": "Shift+drag to select text in the browser; a plain drag selects in tmux and copies on release.",
"dismissHint": "Dismiss",
"find": "Find in terminal",
"findPrev": "Previous match",
"findNext": "Next match",
"findClose": "Close find"
}
}
+42 -4
View File
@@ -74,7 +74,7 @@
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
"changes.commentSend": "Dán vào pane",
"changes.commentCancel": "Huỷ",
"changes.commentNoPane": "Workspace này chưa mở pane nào — hãy mở session trước",
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
"changes.layout.split": "Hai cột",
"changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…",
@@ -390,7 +390,7 @@
"panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một workspace hiện tối đa 4 pane",
"panes.newTerminal": "Terminal mới",
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
"panes.close": "Ẩn pane (session vẫn chạy dưới dạng tab)",
"panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace",
"sessionMenu.open": "Mở",
@@ -401,9 +401,47 @@
"panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Đóng pane",
"panes.closeShort": "Ẩn pane",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
"changes.copySubject": "Sao chép commit message"
"changes.copySubject": "Sao chép commit message",
"replay.live": "Session này đang mở trong một tab — hãy tiếp tục ở đó.",
"replay.noResume": "{{tool}} không thể tiếp tục (resume) một session theo id, nên hội thoại này chỉ để đọc.",
"replay.newSession": "Bắt đầu session mới",
"tabs.state.awaiting": "Đang chờ bạn",
"tabs.state.error": "Lỗi",
"tabs.state.running": "Đang chạy",
"tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
"tabs.stopConfirm": "Dừng phiên",
"changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi",
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
"tree.state.awaiting": "đang chờ bạn",
"tree.state.error": "lỗi",
"tree.state.live": "đang chạy",
"tree.state.idle": "đã kết thúc",
"tree.showMore_one": "Hiện thêm {{count}}",
"tree.showMore_other": "Hiện thêm {{count}}",
"home.contextPanel": "Ngữ cảnh workspace",
"row.state.needsYou": "cần bạn",
"row.state.error": "lỗi",
"row.state.running": "đang chạy",
"row.state.active": "đang hoạt động",
"row.state.idle": "nghỉ",
"menu.stopTitle_one": "Dừng phiên đang chạy?",
"menu.stopTitle_other": "Dừng {{count}} phiên đang chạy?",
"menu.stopBody": "Mọi tab của workspace này sẽ dừng, kể cả khi đang chạy dở. Có thể tiếp tục các phiên sau từ mục Gần đây.",
"gitBar.done.fetch": "Đã fetch",
"gitBar.done.pull": "Đã pull",
"gitBar.done.push": "Đã push",
"gitBar.done.publish": "Đã publish",
"runtime.listening": "đang lắng nghe",
"runtime.notListening": "không lắng nghe",
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
}
+16 -4
View File
@@ -55,7 +55,7 @@
"approvalOnRequest": "Khi cần",
"cwd": "Thư mục làm việc",
"cwdGroups": {
"dashboard": "Dashboard server",
"dashboard": "Máy chủ dashboard",
"home": "Home",
"repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng"
@@ -79,7 +79,7 @@
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
"permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
@@ -92,7 +92,7 @@
"footer": {
"cost": "Chi phí",
"duration": "Thời lượng",
"exitCode": "Exit code",
"exitCode": "Mã thoát",
"sessionId": "Session ID",
"turns": "Lượt"
},
@@ -134,7 +134,7 @@
"session": "Session"
},
"tokens": {
"cacheRead": "Cache hit",
"cacheRead": "Trúng cache",
"cost": "Chi phí",
"input": "Input",
"label": "Context",
@@ -147,5 +147,17 @@
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh",
"view": "Xem ảnh"
},
"terminal": {
"ended": "[phiên đã kết thúc, mã thoát {{code}}]",
"reconnecting": "Đang kết nối lại… phím gõ chưa tới được phiên cho tới khi terminal kết nối lại.",
"keyboardHint": "Terminal. Nhấn Ctrl+M để bật/tắt chế độ Tab chuyển focus ra khỏi terminal; Ctrl+Shift+F (Cmd+F trên macOS) để tìm chữ.",
"tabMoves": "Tab chuyển focus — Ctrl+M để trả Tab lại cho terminal",
"selectHint": "Shift+kéo để chọn chữ trong trình duyệt; kéo thường sẽ chọn trong tmux và sao chép khi thả chuột.",
"dismissHint": "Bỏ qua",
"find": "Tìm trong terminal",
"findPrev": "Kết quả trước",
"findNext": "Kết quả sau",
"findClose": "Đóng tìm kiếm"
}
}