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:
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
||||
|
||||
@@ -10,12 +12,19 @@ interface Props {
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
label: string;
|
||||
// v1.10.3: tells the pane it's the currently visible/focused one in the
|
||||
// grid. Drives a refit so a terminal that was hidden (e.g. behind a
|
||||
// maximize toggle) snaps to the right dimensions when it reappears.
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
|
||||
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
|
||||
// v1.10.3: terminal background matches the pane container's `bg-[#0b0f14]`
|
||||
// so any sub-cell rounding gap at the right/bottom edge is invisible. If
|
||||
// the workspace theme changes, update both this and the container className.
|
||||
const TERM_BG = '#0b0f14';
|
||||
|
||||
const XTERM_THEME = {
|
||||
background: '#0b0f14',
|
||||
background: TERM_BG,
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#82aaff',
|
||||
selectionBackground: '#1d3b53',
|
||||
@@ -37,9 +46,33 @@ const XTERM_THEME = {
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||
// v1.10.3 Issue 5: override xterm's default layout so the canvas stretches
|
||||
// to the full container width. The `!important` is necessary because xterm's
|
||||
// own stylesheet (loaded via `import 'xterm/css/xterm.css'`) sets inline
|
||||
// width/height that we need to override. Also hides the scrollbar — touch /
|
||||
// trackpad / wheel still scroll, the chrome just isn't drawn.
|
||||
const XTERM_STYLE_OVERRIDES = `
|
||||
.xterm { width: 100% !important; height: 100% !important; }
|
||||
.xterm .xterm-screen { width: 100% !important; }
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
`;
|
||||
|
||||
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -47,6 +80,8 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||
|
||||
let disposed = false;
|
||||
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
|
||||
@@ -54,10 +89,14 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
scrollback: 10_000,
|
||||
fastScrollModifier: 'shift',
|
||||
altClickMovesCursor: false,
|
||||
theme: XTERM_THEME,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
fitRef.current = fit;
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
@@ -67,101 +106,279 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||
/* container not yet sized */
|
||||
}
|
||||
|
||||
// POST start kicks the tmux window into existence before the WS upgrade.
|
||||
// It's idempotent so a refresh just no-ops. Failures fall through to the
|
||||
// WS handler which will also call ensureWindow.
|
||||
api.terminals.start(sessionId, paneId).catch(() => {
|
||||
/* surfaced by WS error if it matters */
|
||||
// v1.10.3 Issue 5: web-font metrics aren't final at mount. Re-fit after
|
||||
// the JetBrains Mono variable font lands so column count matches what
|
||||
// the canvas actually paints. document.fonts.ready resolves once the
|
||||
// initial font set is loaded; chaining .fit() on it is a one-shot.
|
||||
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready
|
||||
.then(() => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
|
||||
// v1.10.3 copy/paste: xterm v5 ships no clipboard addon — bind manually.
|
||||
// Cmd/Ctrl-C + selection → copy, swallow keystroke (no ^C)
|
||||
// Cmd/Ctrl-C, no selection → fall through (xterm sends SIGINT)
|
||||
// Cmd/Ctrl-Shift-C → ALWAYS swallow; copy if selection, no-op otherwise
|
||||
// Cmd/Ctrl-V / Cmd/Ctrl-Shift-V → paste from clipboard, swallow
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return true;
|
||||
const isC = e.key === 'c' || e.key === 'C';
|
||||
const isV = e.key === 'v' || e.key === 'V';
|
||||
if (isC) {
|
||||
if (term.hasSelection()) {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => {
|
||||
toast.error('Clipboard write failed');
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// No selection: Shift means "force-swallow" (no ^C); without Shift
|
||||
// we let xterm send SIGINT.
|
||||
return !e.shiftKey;
|
||||
}
|
||||
if (isV) {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (!text) return;
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Clipboard read failed');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const initialCols = term.cols;
|
||||
const initialRows = term.rows;
|
||||
const wsUrl =
|
||||
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
|
||||
`?cols=${initialCols}&rows=${initialRows}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
function buildUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
return (
|
||||
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
|
||||
`?cols=${cols}&rows=${rows}`
|
||||
);
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
// Control frame from server (e.g. {"type":"exit","code":0}).
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
|
||||
if (parsed.type === 'exit') {
|
||||
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
|
||||
function connect(): void {
|
||||
if (disposed) return;
|
||||
try {
|
||||
const ws = new WebSocket(buildUrl());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (disposed) return;
|
||||
attempts = 0;
|
||||
setConnState('open');
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
|
||||
if (parsed.type === 'exit') {
|
||||
term.write(
|
||||
`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — fall through and write as text */
|
||||
}
|
||||
term.write(e.data);
|
||||
} else {
|
||||
term.write(new Uint8Array(e.data));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnState('disconnected');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — fall through and write as text */
|
||||
}
|
||||
term.write(e.data);
|
||||
} else {
|
||||
term.write(new Uint8Array(e.data));
|
||||
}
|
||||
};
|
||||
attempts += 1;
|
||||
setConnState('reconnecting');
|
||||
const delay = 500 * Math.pow(2, attempts - 1);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onerror = () => {
|
||||
// onclose follows — let it own the retry decision.
|
||||
};
|
||||
} catch {
|
||||
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnState('disconnected');
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
setConnState('reconnecting');
|
||||
reconnectTimer = setTimeout(connect, 500 * Math.pow(2, attempts - 1));
|
||||
}
|
||||
}
|
||||
|
||||
function manualReconnect(): void {
|
||||
if (disposed) return;
|
||||
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
||||
};
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
attempts = 0;
|
||||
setConnState('reconnecting');
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reconnectTimer = setTimeout(connect, 50);
|
||||
}
|
||||
reconnectRef.current = manualReconnect;
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
const fireResize = () => {
|
||||
const fireResize = (): void => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
|
||||
api.terminals.resize(sessionId, paneId, term.cols, term.rows).catch(() => {
|
||||
/* transient — next resize will catch up */
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||
resizeDebounceTimer = setTimeout(fireResize, 100);
|
||||
// v1.10.3 Issue 5: defer the actual fit to the next frame so the
|
||||
// browser has applied the new layout before we measure cell sizes.
|
||||
resizeDebounceTimer = setTimeout(() => {
|
||||
requestAnimationFrame(fireResize);
|
||||
}, 100);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
const unregister = terminalsRegistry.register(paneId, label);
|
||||
const onVis = (): void => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
||||
manualReconnect();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
const unregister = terminalsRegistry.register(paneId, label, () => {
|
||||
try {
|
||||
term.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||
if (pane_id !== paneId) return;
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||
ws.send(payload);
|
||||
});
|
||||
|
||||
api.terminals.start(sessionId, paneId).catch(() => {});
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
unsubscribe();
|
||||
unregister();
|
||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
||||
ro.disconnect();
|
||||
try {
|
||||
ws.close();
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
reconnectRef.current = () => {};
|
||||
};
|
||||
}, [sessionId, paneId, label]);
|
||||
|
||||
// v1.10.3 Issue 5 refit trigger: when this pane becomes active (e.g. after
|
||||
// a mobile pane switch or an unmaximize), the container's measured size
|
||||
// may differ from when xterm last fit. rAF defers the measurement to after
|
||||
// the layout pass that the visibility change triggered.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const fit = fitRef.current;
|
||||
if (!fit) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full bg-[#0b0f14] overflow-hidden"
|
||||
data-testid="terminal-pane"
|
||||
/>
|
||||
// v1.10.3 Issue 5: flex-1 + min-w-0 + self-stretch + w-full lets the
|
||||
// pane absorb all available horizontal space without leaving a gap on
|
||||
// either side of the xterm canvas.
|
||||
className="relative flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]"
|
||||
>
|
||||
{/* v1.10.3 Issue 5: per-component CSS override (not global). React
|
||||
deduplicates identical <style> bodies in modern browsers, so
|
||||
multiple terminal panes don't bloat the head. */}
|
||||
<style>{XTERM_STYLE_OVERRIDES}</style>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full overflow-hidden"
|
||||
style={{ touchAction: 'pan-y', background: TERM_BG }}
|
||||
data-testid="terminal-pane"
|
||||
/>
|
||||
{connState === 'reconnecting' && (
|
||||
<div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
<span>Reconnecting…</span>
|
||||
</div>
|
||||
)}
|
||||
{connState === 'disconnected' && (
|
||||
<div className="absolute inset-x-0 top-0 bg-destructive/85 text-destructive-foreground text-xs px-3 py-1 flex items-center gap-2">
|
||||
<span>Disconnected from terminal server.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reconnectRef.current()}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded bg-background/20 hover:bg-background/30 px-2 py-0.5"
|
||||
>
|
||||
<RefreshCw size={12} /> Reconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user