merge v1.10.3-booterm-ux
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
set -g default-terminal "screen-256color"
|
set -g default-terminal "screen-256color"
|
||||||
set -g history-limit 50000
|
set -g history-limit 50000
|
||||||
set -g mouse on
|
# v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch
|
||||||
|
# events at the protocol level, so xterm.js never saw them and the viewport
|
||||||
|
# couldn't scroll on mobile. With mouse off, xterm.js handles scrollback
|
||||||
|
# natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y).
|
||||||
|
# Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for
|
||||||
|
# the homelab single-user setup.
|
||||||
setw -g mode-keys vi
|
setw -g mode-keys vi
|
||||||
set -g status off
|
set -g status off
|
||||||
set -g destroy-unattached off
|
set -g destroy-unattached off
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export function Workspace({
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
|
active={idx === activePaneIdx}
|
||||||
/>
|
/>
|
||||||
) : pane.kind === 'chat' && pane.chatId ? (
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Terminal } from 'xterm';
|
import { Terminal } from 'xterm';
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
||||||
|
|
||||||
@@ -10,12 +12,19 @@ interface Props {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
label: 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
|
// v1.10.3: terminal background matches the pane container's `bg-[#0b0f14]`
|
||||||
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
|
// 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 = {
|
const XTERM_THEME = {
|
||||||
background: '#0b0f14',
|
background: TERM_BG,
|
||||||
foreground: '#d6deeb',
|
foreground: '#d6deeb',
|
||||||
cursor: '#82aaff',
|
cursor: '#82aaff',
|
||||||
selectionBackground: '#1d3b53',
|
selectionBackground: '#1d3b53',
|
||||||
@@ -37,9 +46,33 @@ const XTERM_THEME = {
|
|||||||
brightWhite: '#ffffff',
|
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 containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const wsRef = useRef<WebSocket | 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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -47,6 +80,8 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
|||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
|
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
|
||||||
@@ -54,10 +89,14 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
scrollback: 10_000,
|
scrollback: 10_000,
|
||||||
|
fastScrollModifier: 'shift',
|
||||||
|
altClickMovesCursor: false,
|
||||||
theme: XTERM_THEME,
|
theme: XTERM_THEME,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
});
|
});
|
||||||
|
termRef.current = term;
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
|
fitRef.current = fit;
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
term.open(container);
|
term.open(container);
|
||||||
@@ -67,30 +106,97 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
|||||||
/* container not yet sized */
|
/* container not yet sized */
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST start kicks the tmux window into existence before the WS upgrade.
|
// v1.10.3 Issue 5: web-font metrics aren't final at mount. Re-fit after
|
||||||
// It's idempotent so a refresh just no-ops. Failures fall through to the
|
// the JetBrains Mono variable font lands so column count matches what
|
||||||
// WS handler which will also call ensureWindow.
|
// the canvas actually paints. document.fonts.ready resolves once the
|
||||||
api.terminals.start(sessionId, paneId).catch(() => {
|
// initial font set is loaded; chaining .fit() on it is a one-shot.
|
||||||
/* surfaced by WS error if it matters */
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function buildUrl(): string {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const initialCols = term.cols;
|
const cols = term.cols;
|
||||||
const initialRows = term.rows;
|
const rows = term.rows;
|
||||||
const wsUrl =
|
return (
|
||||||
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
|
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
|
||||||
`?cols=${initialCols}&rows=${initialRows}`;
|
`?cols=${cols}&rows=${rows}`
|
||||||
const ws = new WebSocket(wsUrl);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(): void {
|
||||||
|
if (disposed) return;
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(buildUrl());
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
attempts = 0;
|
||||||
|
setConnState('open');
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
if (typeof e.data === 'string') {
|
if (typeof e.data === 'string') {
|
||||||
// Control frame from server (e.g. {"type":"exit","code":0}).
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
|
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
|
||||||
if (parsed.type === 'exit') {
|
if (parsed.type === 'exit') {
|
||||||
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
|
term.write(
|
||||||
|
`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -104,64 +210,175 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
|
|||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
setConnState('disconnected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts += 1;
|
||||||
|
setConnState('reconnecting');
|
||||||
|
const delay = 500 * Math.pow(2, attempts - 1);
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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) => {
|
term.onData((data) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const ws = wsRef.current;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(data);
|
ws.send(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fireResize = () => {
|
const fireResize = (): void => {
|
||||||
try {
|
try {
|
||||||
fit.fit();
|
fit.fit();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cols = term.cols;
|
api.terminals.resize(sessionId, paneId, term.cols, term.rows).catch(() => {
|
||||||
const rows = term.rows;
|
|
||||||
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
|
|
||||||
/* transient — next resize will catch up */
|
/* transient — next resize will catch up */
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
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);
|
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 }) => {
|
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||||
if (pane_id !== paneId) return;
|
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`;
|
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||||
ws.send(payload);
|
ws.send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.terminals.start(sessionId, paneId).catch(() => {});
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
unregister();
|
unregister();
|
||||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||||
|
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
wsRef.current?.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
reconnectRef.current = () => {};
|
||||||
};
|
};
|
||||||
}, [sessionId, paneId, label]);
|
}, [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 (
|
return (
|
||||||
|
<div
|
||||||
|
// 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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full bg-[#0b0f14] overflow-hidden"
|
className="w-full h-full overflow-hidden"
|
||||||
|
style={{ touchAction: 'pan-y', background: TERM_BG }}
|
||||||
data-testid="terminal-pane"
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ function generateId(): string {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPane(): WorkspacePane {
|
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
|
||||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
// 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 {
|
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
|
// 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
|
// persist in localStorage along with chat panes so a refresh resumes the
|
||||||
// same tmux window via the idempotent start endpoint.
|
// same tmux window via the idempotent start endpoint.
|
||||||
function terminalPane(): WorkspacePane {
|
function terminalPane(id: string = generateId()): WorkspacePane {
|
||||||
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// 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;
|
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
||||||
closeAllTabs: (paneIdx: number) => void;
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
showLandingPage: (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
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// 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.
|
// 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') {
|
if (kind === 'agent') {
|
||||||
toast('Agent panes coming in BooCoder');
|
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) => {
|
setPanes((prev) => {
|
||||||
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
||||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
|
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
|
||||||
const next = [...prev, newPane];
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
|
success = true;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return success ? newPaneId : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleSettingsPane = useCallback(() => {
|
const toggleSettingsPane = useCallback(() => {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
|||||||
export interface TerminalRegistration {
|
export interface TerminalRegistration {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
label: 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<string, TerminalRegistration>();
|
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||||
@@ -60,8 +63,8 @@ function notifyRegistry(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const terminalsRegistry = {
|
export const terminalsRegistry = {
|
||||||
register(paneId: string, label: string): () => void {
|
register(paneId: string, label: string, focus: () => void): () => void {
|
||||||
terminalRegistry.set(paneId, { paneId, label });
|
terminalRegistry.set(paneId, { paneId, label, focus });
|
||||||
notifyRegistry();
|
notifyRegistry();
|
||||||
return () => {
|
return () => {
|
||||||
terminalRegistry.delete(paneId);
|
terminalRegistry.delete(paneId);
|
||||||
@@ -71,6 +74,9 @@ export const terminalsRegistry = {
|
|||||||
list(): TerminalRegistration[] {
|
list(): TerminalRegistration[] {
|
||||||
return Array.from(terminalRegistry.values());
|
return Array.from(terminalRegistry.values());
|
||||||
},
|
},
|
||||||
|
get(paneId: string): TerminalRegistration | undefined {
|
||||||
|
return terminalRegistry.get(paneId);
|
||||||
|
},
|
||||||
subscribe(listener: Listener<void>): () => void {
|
subscribe(listener: Listener<void>): () => void {
|
||||||
registryListeners.add(listener);
|
registryListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { terminalsRegistry } from '@/lib/events';
|
||||||
import { useActivePane } from '@/hooks/useActivePane';
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
@@ -170,6 +171,110 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
[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() {
|
async function saveName() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -264,7 +369,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
onRenameChat={renameChat}
|
onRenameChat={renameChat}
|
||||||
/>
|
/>
|
||||||
<NewPaneMenu
|
<NewPaneMenu
|
||||||
onAddPane={addSplitPane}
|
onAddPane={addPaneAndSwitch}
|
||||||
disabled={panes.length >= MAX_PANES}
|
disabled={panes.length >= MAX_PANES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user