From 875db86e310ccd922b32c56adb93d3db9beb9e15 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 19 May 2026 13:52:44 +0000 Subject: [PATCH] v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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 +
+ {connState === 'reconnecting' && ( +
+ + Reconnecting… +
+ )} + {connState === 'disconnected' && ( +
+ Disconnected from terminal server. + +
+ )} +
); } diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 9c34273..d189df7 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -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(() => { diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index a41e970..7eaa9a6 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -44,6 +44,9 @@ export const sendToTerminal = createEvent(); export interface TerminalRegistration { paneId: string; label: string; + // v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm + // input layer. TerminalPane binds this to term.focus(). + focus: () => void; } const terminalRegistry = new Map(); @@ -60,8 +63,8 @@ function notifyRegistry(): void { } export const terminalsRegistry = { - register(paneId: string, label: string): () => void { - terminalRegistry.set(paneId, { paneId, label }); + register(paneId: string, label: string, focus: () => void): () => void { + terminalRegistry.set(paneId, { paneId, label, focus }); notifyRegistry(); return () => { terminalRegistry.delete(paneId); @@ -71,6 +74,9 @@ export const terminalsRegistry = { list(): TerminalRegistration[] { return Array.from(terminalRegistry.values()); }, + get(paneId: string): TerminalRegistration | undefined { + return terminalRegistry.get(paneId); + }, subscribe(listener: Listener): () => void { registryListeners.add(listener); return () => { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 8eb58e6..97000d6 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -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} /> = MAX_PANES} />