v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts
Five issues + keyboard shortcuts across booterm and the workspace shell. Auto-switch on create (mobile): addSplitPane now returns the new pane id; Session.tsx wraps it with addPaneAndSwitch which pushes ?pane=<newId> on mobile so the URL-sync effect doesn't fight the just-set activePaneIdx. NewPaneMenu uses the wrapper; desktop Split dropdown is unaffected. Tab-away reconnect: TerminalPane has a connect()/manualReconnect() state machine. ws.onclose backs off 500ms/1s/2s × 3 attempts, then surfaces a [Disconnected] banner with a Reconnect button. visibilitychange listener calls manualReconnect when the tab returns and the WS isn't OPEN. tmux session persists server-side so scrollback is intact on resume. Copy/paste: attachCustomKeyEventHandler binds Cmd/Ctrl-C (copy if selection, else send ^C), Cmd/Ctrl-Shift-C (always swallow — copy if any, no-op otherwise — never sends ^C), Cmd/Ctrl-V and Cmd/Ctrl-Shift-V (navigator.clipboard.readText → ws.send). No custom right-click menu — browser's native menu is preserved. Scroll: removed `set -g mouse on` from tmux.conf so xterm.js sees wheel and touch events natively. scrollback: 10_000, fastScrollModifier: 'shift', altClickMovesCursor: false. Container has touch-action: pan-y for mobile. Right-edge gap: inline <style> overrides xterm's defaults to width:100% height:100% and hides the scrollbar chrome. Host container is flex-1 min-w-0 self-stretch w-full. Three refit triggers: ResizeObserver (rAF-wrapped), document.fonts.ready, and useEffect on the new active prop. Background color matched between outer div, inner div, and xterm theme. Keyboard shortcuts in Session.tsx (window-level keydown): Cmd/Ctrl+` focus active terminal, else jump to last Cmd/Ctrl+Shift+T new terminal pane Cmd/Ctrl+Shift+C new chat pane (defers to xterm copy if focused) Cmd/Ctrl+W close active pane Cmd/Ctrl+Tab/Shift+Tab cycle next / prev pane Cmd/Ctrl+1..9 jump to pane N terminalsRegistry gains a focus() callback per registration so Cmd+` can call term.focus() on the active terminal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session as SessionType } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { terminalsRegistry } from '@/lib/events';
|
||||
import { useActivePane } from '@/hooks/useActivePane';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
@@ -170,6 +171,110 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
||||
);
|
||||
|
||||
// v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync
|
||||
// effect below sees a stale ?pane= and immediately resets the index. Push
|
||||
// the new pane's id to the URL atomically so the effect's next pass sees a
|
||||
// matching id and is a no-op. Desktop has no URL pane state — fall through.
|
||||
const addPaneAndSwitch = useCallback(
|
||||
(kind: 'chat' | 'terminal' | 'agent') => {
|
||||
const newPaneId = addSplitPane(kind);
|
||||
if (newPaneId === null) return;
|
||||
if (isMobile) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('pane', newPaneId);
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
}
|
||||
},
|
||||
[addSplitPane, isMobile, navigate, location.pathname, location.search],
|
||||
);
|
||||
|
||||
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
|
||||
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
|
||||
// (which has its own copy binding for that combo); everything else fires
|
||||
// regardless of focus. Cmd-W and Cmd-T are typically reserved by the
|
||||
// browser — preventDefault() works in most browsers but not all.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
const key = e.key.toLowerCase();
|
||||
const target = e.target;
|
||||
const inXterm = target instanceof Element && target.closest('.xterm') !== null;
|
||||
|
||||
// Cmd/Ctrl + ` — focus the active terminal or jump to the most recent
|
||||
// terminal pane and focus it. No-op if there are no terminal panes.
|
||||
if (key === '`') {
|
||||
e.preventDefault();
|
||||
const activePane = panes[activePaneIdx];
|
||||
if (activePane?.kind === 'terminal') {
|
||||
terminalsRegistry.get(activePane.id)?.focus();
|
||||
return;
|
||||
}
|
||||
let lastTermIdx = -1;
|
||||
for (let i = panes.length - 1; i >= 0; i--) {
|
||||
if (panes[i]?.kind === 'terminal') {
|
||||
lastTermIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastTermIdx < 0) return;
|
||||
const target = panes[lastTermIdx];
|
||||
switchActivePane(lastTermIdx);
|
||||
if (target) {
|
||||
// The terminal may have just mounted on mobile (it was return-null
|
||||
// before the switch). Defer focus until the new render commits.
|
||||
setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Shift + T — new terminal pane and switch to it.
|
||||
if (key === 't' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addPaneAndSwitch('terminal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's
|
||||
// own Shift-C binding is "copy selection" — defer to it when in xterm.
|
||||
if (key === 'c' && e.shiftKey) {
|
||||
if (inXterm) return;
|
||||
e.preventDefault();
|
||||
addPaneAndSwitch('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W — close the active pane.
|
||||
if (key === 'w' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
removePane(activePaneIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
|
||||
if (key === 'tab') {
|
||||
if (panes.length <= 1) return;
|
||||
e.preventDefault();
|
||||
const dir = e.shiftKey ? -1 : 1;
|
||||
const next = (activePaneIdx + dir + panes.length) % panes.length;
|
||||
switchActivePane(next);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 1..9 — direct jump to pane N.
|
||||
if (/^[1-9]$/.test(key)) {
|
||||
const idx = parseInt(key, 10) - 1;
|
||||
if (idx < panes.length) {
|
||||
e.preventDefault();
|
||||
switchActivePane(idx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]);
|
||||
|
||||
async function saveName() {
|
||||
if (!session) return;
|
||||
const trimmed = name.trim();
|
||||
@@ -264,7 +369,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
onRenameChat={renameChat}
|
||||
/>
|
||||
<NewPaneMenu
|
||||
onAddPane={addSplitPane}
|
||||
onAddPane={addPaneAndSwitch}
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user