merge v1.10.3-booterm-ux
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
set -g default-terminal "screen-256color"
|
||||
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
|
||||
set -g status off
|
||||
set -g destroy-unattached off
|
||||
|
||||
@@ -271,6 +271,7 @@ export function Workspace({
|
||||
sessionId={sessionId}
|
||||
paneId={pane.id}
|
||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||
active={idx === activePaneIdx}
|
||||
/>
|
||||
) : pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -44,6 +44,9 @@ export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
||||
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<string, TerminalRegistration>();
|
||||
@@ -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>): () => void {
|
||||
registryListeners.add(listener);
|
||||
return () => {
|
||||
|
||||
@@ -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