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:
2026-05-19 13:52:44 +00:00
parent 8eaf9591dc
commit 875db86e31
6 changed files with 411 additions and 63 deletions

View File

@@ -11,8 +11,11 @@ function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// setPanes updater so the new pane's id can be returned synchronously to the
// caller (needed for mobile URL state).
function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
@@ -23,8 +26,8 @@ function chatPane(chatId: string): WorkspacePane {
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
// persist in localStorage along with chat panes so a refresh resumes the
// same tmux window via the idempotent start endpoint.
function terminalPane(): WorkspacePane {
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
@@ -80,7 +83,11 @@ export interface UseWorkspacePanesResult {
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
// id to update mobile URL state so the URL-sync effect doesn't fight the
// freshly-set activePaneIdx.
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
@@ -241,22 +248,29 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
return null;
}
// Generate the id outside the updater so we can return it deterministically.
// setPanes's updater can be invoked twice in strict mode; using a fixed id
// ensures both invocations agree and the returned id matches what landed.
const newPaneId = generateId();
let success = false;
setPanes((prev) => {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
return next;
});
return success ? newPaneId : null;
}, []);
const toggleSettingsPane = useCallback(() => {