refactor: codebase audit cleanup — dead code, dedup, module splits
Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup.
This commit is contained in:
179
apps/web/src/hooks/terminal/useTerminalFit.ts
Normal file
179
apps/web/src/hooks/terminal/useTerminalFit.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
// useTerminalFit — terminal measurement + the refit triggers that are purely
|
||||
// about sizing (fonts.ready rAF, 150ms settle timeout, ResizeObserver,
|
||||
// visualViewport resize). The two refit triggers that are entangled with the
|
||||
// connection lifecycle — the init rAF (fit → /start → connect) and the
|
||||
// visibilitychange handler (reconnect-or-refit) — live in useTerminalSocket
|
||||
// and call this hook's `fit()`; that keeps each of those single-rAF / if-else
|
||||
// sequences intact rather than splitting one listener across two hooks.
|
||||
//
|
||||
// Exposes a stable `fit()` (the FitAddon-bypass measurement, with the iOS
|
||||
// keyboard clip) and `getSize()`/`setSize()` over the read-latest size mirror.
|
||||
|
||||
// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
|
||||
// is internal but stable across the package versions we ship. This is the ONE
|
||||
// place that reaches into the private cell metrics; everything else funnels
|
||||
// through `cellSize` or `readCellMetrics`.
|
||||
function readCellMetrics(term: Terminal): { w: number; h: number } | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dims = (term as any)._core?._renderService?.dimensions?.css?.cell;
|
||||
if (dims && dims.width > 0 && dims.height > 0) {
|
||||
return { w: dims.width, h: dims.height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cell metrics with a getBoundingClientRect fallback for callers (touch
|
||||
// point→cell mapping) that need an estimate even before xterm has measured.
|
||||
// `fit()` deliberately does NOT use this fallback — it bails until real
|
||||
// metrics exist so it never resizes against a fallback estimate.
|
||||
export function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
|
||||
const m = readCellMetrics(term);
|
||||
if (m) return m;
|
||||
const rect = container.getBoundingClientRect();
|
||||
return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
|
||||
}
|
||||
|
||||
interface FitDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
export interface TerminalFit {
|
||||
fit: () => void;
|
||||
getSize: () => { cols: number; rows: number };
|
||||
setSize: (cols: number, rows: number) => void;
|
||||
}
|
||||
|
||||
export function useTerminalFit({ termRef, containerRef, sessionId, paneId }: FitDeps): TerminalFit {
|
||||
// Last size pushed through term.onResize. Mirrors xterm's authoritative
|
||||
// cols/rows; consulted on every WS open so the resize frame carries the
|
||||
// currently-measured size, not a stale lastSize from before the latest fit.
|
||||
const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
|
||||
|
||||
const getSize = useCallback(() => lastSizeRef.current, []);
|
||||
const setSize = useCallback((cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows };
|
||||
}, []);
|
||||
|
||||
// Bypass FitAddon's proposeDimensions(), which subtracts the native
|
||||
// scrollbar's reserved width even after CSS hides it (boolab fix). We
|
||||
// compute cols/rows directly from host.clientWidth/Height ÷ cell metrics.
|
||||
// Falls through (no-op) if cell metrics aren't measured yet — a later
|
||||
// refit (fonts.ready / setTimeout / ResizeObserver) will catch it.
|
||||
//
|
||||
// v1.10.8d: row count uses the VISIBLE height (clipped against the
|
||||
// visualViewport) rather than raw clientHeight. h-dvh on the root only
|
||||
// shrinks for the URL bar — not the iOS keyboard — so without the clip
|
||||
// we allocate rows for the area hidden behind the keyboard, and bash's
|
||||
// cursor (pushed down by MOTD on SSH login) ends up below the fold.
|
||||
const fit = useCallback((): void => {
|
||||
const t = termRef.current;
|
||||
if (!t) return;
|
||||
const host = containerRef.current;
|
||||
if (!host) return;
|
||||
if (!t.element || !(t.element as HTMLElement).offsetParent) return;
|
||||
const m = readCellMetrics(t);
|
||||
if (!m) return;
|
||||
const cellW = m.w;
|
||||
const cellH = m.h;
|
||||
let visibleHeight = host.clientHeight;
|
||||
const vp2 = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (vp2) {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// visualViewport.height is the layout viewport minus the keyboard.
|
||||
// rect.bottom is in layout-viewport coordinates. clip to whichever
|
||||
// is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1.
|
||||
const visibleBottom = Math.min(rect.bottom, vp2.height);
|
||||
const clipped = Math.max(0, visibleBottom - rect.top);
|
||||
if (clipped > 0) visibleHeight = clipped;
|
||||
}
|
||||
const cols = Math.max(2, Math.floor(host.clientWidth / cellW));
|
||||
const rows = Math.max(1, Math.floor(visibleHeight / cellH));
|
||||
if (cols !== t.cols || rows !== t.rows) {
|
||||
try {
|
||||
t.resize(cols, rows);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [termRef, containerRef]);
|
||||
|
||||
// Pure-fit refit triggers. Re-run on session/pane change so a recreated
|
||||
// terminal gets the same fonts.ready / settle / ResizeObserver / vpResize
|
||||
// coverage the pre-split single effect gave it.
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
const ctr = containerRef.current;
|
||||
if (!term || !ctr) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
// Boolab pattern: term measures cell metrics on open() against whatever
|
||||
// font is registered at that instant; if the font hasn't loaded yet we
|
||||
// get a fallback measurement and the wrong cols. Refit when
|
||||
// document.fonts.ready resolves.
|
||||
let fontRaf: number | null = null;
|
||||
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready
|
||||
.then(() => {
|
||||
if (disposed) return;
|
||||
fontRaf = requestAnimationFrame(() => fit());
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
|
||||
// Second-pass refit: mobile browsers can settle layout slightly late,
|
||||
// causing the rAF fit to measure a stale clientWidth.
|
||||
const delayedFit = setTimeout(() => fit(), 150);
|
||||
|
||||
// ResizeObserver: refit on any container size change. No debounce —
|
||||
// term.onResize itself is the throttle (no-op when cols/rows unchanged).
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit();
|
||||
});
|
||||
ro.observe(ctr);
|
||||
|
||||
// v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or
|
||||
// closes — the layout viewport stays full-height (so our h-dvh root
|
||||
// doesn't shrink), but the visual viewport contracts above the keyboard.
|
||||
// Refit so xterm's row count matches the visible area, then scroll to
|
||||
// bottom so the cursor stays above the keyboard fold (boolab pattern).
|
||||
let onVpResize: (() => void) | null = null;
|
||||
const vp = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (vp) {
|
||||
onVpResize = (): void => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
const t = termRef.current;
|
||||
if (t) {
|
||||
try {
|
||||
t.scrollToBottom();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
vp.addEventListener('resize', onVpResize);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (fontRaf !== null) cancelAnimationFrame(fontRaf);
|
||||
clearTimeout(delayedFit);
|
||||
ro.disconnect();
|
||||
if (vp && onVpResize) vp.removeEventListener('resize', onVpResize);
|
||||
};
|
||||
// sessionId/paneId drive re-run on terminal recreation (term is rebuilt by
|
||||
// the orchestrator on those deps); fit is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, fit]);
|
||||
|
||||
return { fit, getSize, setSize };
|
||||
}
|
||||
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
chatInputsRegistry,
|
||||
sendToChat,
|
||||
terminalsRegistry,
|
||||
type ChatInputRegistration,
|
||||
} from '@/lib/events';
|
||||
import { cellSize } from './useTerminalFit';
|
||||
|
||||
// useTerminalSelection — the touch long-press selection + word-range gesture
|
||||
// subsystem, the document pointer/contextmenu handlers, clipboard custom keys,
|
||||
// the terminal + chat-input registries, and the floating-menu actions. All of
|
||||
// it is independent of the WS/fit path and was kept verbatim from v1.10.4.
|
||||
|
||||
const LONG_PRESS_MS = 500;
|
||||
const LONG_PRESS_TOLERANCE_PX = 10;
|
||||
|
||||
function pointToCell(
|
||||
term: Terminal,
|
||||
container: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { col: number; bufferRow: number } {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const { w, h } = cellSize(term, container);
|
||||
const localX = Math.max(0, clientX - rect.left);
|
||||
const localY = Math.max(0, clientY - rect.top);
|
||||
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
|
||||
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
|
||||
const bufferRow = term.buffer.active.viewportY + screenRow;
|
||||
return { col, bufferRow };
|
||||
}
|
||||
|
||||
const WORD_RE = /[\w.~$\-/]+/g;
|
||||
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
|
||||
for (const m of line.matchAll(WORD_RE)) {
|
||||
const start = m.index ?? 0;
|
||||
const end = start + m[0].length;
|
||||
if (col >= start && col < end) return { start, end };
|
||||
if (start > col) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SelectionDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
label: string;
|
||||
send: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelectionActions {
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
search: () => void;
|
||||
sendToChat: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelection {
|
||||
menu: { x: number; y: number } | null;
|
||||
setMenu: React.Dispatch<React.SetStateAction<{ x: number; y: number } | null>>;
|
||||
searchOpen: boolean;
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
chatInputs: ChatInputRegistration[];
|
||||
hasSelection: boolean;
|
||||
actions: TerminalSelectionActions;
|
||||
}
|
||||
|
||||
export function useTerminalSelection({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
label,
|
||||
send,
|
||||
}: SelectionDeps): TerminalSelection {
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
|
||||
|
||||
const pasteFromClipboard = useCallback((): void => {
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (!text) return;
|
||||
send(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
// ============================================================
|
||||
// v1.10.4 features (long-press menu, right-click, custom keys)
|
||||
// Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
|
||||
// Re-bound on session/pane change so the gesture closures reference the
|
||||
// recreated terminal.
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
const termInit = termRef.current;
|
||||
const ctrInit = containerRef.current;
|
||||
if (!termInit || !ctrInit) return;
|
||||
// Non-null bindings so the gesture closures below keep the narrowing.
|
||||
const term: Terminal = termInit;
|
||||
const ctr: HTMLDivElement = ctrInit;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
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';
|
||||
const isF = e.key === 'f' || e.key === 'F';
|
||||
if (isC) {
|
||||
if (term.hasSelection()) {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => {
|
||||
toast.error('Clipboard write failed');
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !e.shiftKey;
|
||||
}
|
||||
if (isV) {
|
||||
pasteFromClipboard();
|
||||
return false;
|
||||
}
|
||||
if (isF) {
|
||||
setSearchOpen(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Long-press selection + floating menu (touch).
|
||||
let lpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lpStart: { x: number; y: number } | null = null;
|
||||
let lpAnchor: { col: number; bufferRow: number } | null = null;
|
||||
let inSelection = false;
|
||||
function clearLp(): void {
|
||||
if (lpTimer !== null) {
|
||||
clearTimeout(lpTimer);
|
||||
lpTimer = null;
|
||||
}
|
||||
lpStart = null;
|
||||
}
|
||||
function selectWord(col: number, bufferRow: number): boolean {
|
||||
const line = term.buffer.active.getLine(bufferRow);
|
||||
if (!line) return false;
|
||||
const text = line.translateToString(true);
|
||||
const range = wordRangeAt(text, col);
|
||||
if (!range) return false;
|
||||
term.select(range.start, bufferRow, range.end - range.start);
|
||||
return true;
|
||||
}
|
||||
function extendSelection(
|
||||
anchor: { col: number; bufferRow: number },
|
||||
to: { col: number; bufferRow: number },
|
||||
): void {
|
||||
const a = anchor;
|
||||
const b = to;
|
||||
let s: { col: number; row: number };
|
||||
let e: { col: number; row: number };
|
||||
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
|
||||
s = { col: a.col, row: a.bufferRow };
|
||||
e = { col: b.col, row: b.bufferRow };
|
||||
} else {
|
||||
s = { col: b.col, row: b.bufferRow };
|
||||
e = { col: a.col, row: a.bufferRow };
|
||||
}
|
||||
const rowsBetween = e.row - s.row;
|
||||
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
|
||||
term.select(s.col, s.row, length);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
|
||||
lpStart = { x: t.clientX, y: t.clientY };
|
||||
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
inSelection = false;
|
||||
lpTimer = setTimeout(() => {
|
||||
if (disposed || !lpAnchor) return;
|
||||
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
|
||||
if (!ok) return;
|
||||
inSelection = true;
|
||||
setMenu({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
function onTouchMove(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (inSelection && lpAnchor) {
|
||||
e.preventDefault();
|
||||
const to = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
extendSelection(lpAnchor, to);
|
||||
return;
|
||||
}
|
||||
if (!lpStart) return;
|
||||
const dx = t.clientX - lpStart.x;
|
||||
const dy = t.clientY - lpStart.y;
|
||||
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
|
||||
clearLp();
|
||||
}
|
||||
}
|
||||
function onTouchEnd(): void {
|
||||
if (!inSelection) clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
function onTouchCancel(): void {
|
||||
clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
ctr.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
ctr.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
ctr.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
|
||||
function onContextMenu(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
ctr.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
function onDocPointerDown(e: PointerEvent): void {
|
||||
const t = e.target as Element | null;
|
||||
if (t && t.closest('[data-term-menu]')) return;
|
||||
setMenu(null);
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (lpTimer !== null) clearTimeout(lpTimer);
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
ctr.removeEventListener('touchstart', onTouchStart);
|
||||
ctr.removeEventListener('touchmove', onTouchMove);
|
||||
ctr.removeEventListener('touchend', onTouchEnd);
|
||||
ctr.removeEventListener('touchcancel', onTouchCancel);
|
||||
ctr.removeEventListener('contextmenu', onContextMenu);
|
||||
// attachCustomKeyEventHandler has no disposable; term.dispose() drops it.
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, send, pasteFromClipboard]);
|
||||
|
||||
// Terminal registry: focus / openSearch / paste hooks for keyboard shortcuts
|
||||
// (Session.tsx) and the pane-header paste button (Workspace.tsx). Keyed on
|
||||
// [paneId, label] ONLY — NOT on sessionId — so a positional `label` renumber
|
||||
// ("Terminal N" after a pane add/remove) refreshes the registry entry
|
||||
// WITHOUT tearing down + reconnecting the live terminal. The focus closure
|
||||
// reads termRef lazily, so it tracks a terminal recreated on a session change
|
||||
// without needing this effect to re-run. (v2 Phase 9 latent-bug fix: `label`
|
||||
// was previously in the xterm-init effect's dep array.)
|
||||
useEffect(() => {
|
||||
const unregister = terminalsRegistry.register(
|
||||
paneId,
|
||||
label,
|
||||
() => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
() => setSearchOpen(true),
|
||||
() => pasteFromClipboard(),
|
||||
);
|
||||
return () => unregister();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneId, label, pasteFromClipboard]);
|
||||
|
||||
// Chat-input registry: populates the floating menu's "Send to chat" submenu.
|
||||
// Independent of session/pane/term — subscribe once.
|
||||
useEffect(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
const unsubChats = chatInputsRegistry.subscribe(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
});
|
||||
return () => unsubChats();
|
||||
}, []);
|
||||
|
||||
function actCopy(): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
|
||||
term.clearSelection();
|
||||
setMenu(null);
|
||||
}
|
||||
function actPaste(): void {
|
||||
const reg = terminalsRegistry.get(paneId);
|
||||
reg?.paste();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSelectAll(): void {
|
||||
termRef.current?.selectAll();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSearch(): void {
|
||||
setSearchOpen(true);
|
||||
setMenu(null);
|
||||
}
|
||||
function actSendToChat(chatId: string): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
sendToChat.emit({ chat_id: chatId, text: sel });
|
||||
setMenu(null);
|
||||
}
|
||||
|
||||
const hasSelection = !!termRef.current?.getSelection();
|
||||
|
||||
return {
|
||||
menu,
|
||||
setMenu,
|
||||
searchOpen,
|
||||
setSearchOpen,
|
||||
chatInputs,
|
||||
hasSelection,
|
||||
actions: {
|
||||
copy: actCopy,
|
||||
paste: actPaste,
|
||||
selectAll: actSelectAll,
|
||||
search: actSearch,
|
||||
sendToChat: actSendToChat,
|
||||
},
|
||||
};
|
||||
}
|
||||
351
apps/web/src/hooks/terminal/useTerminalSocket.ts
Normal file
351
apps/web/src/hooks/terminal/useTerminalSocket.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { api } from '@/api/client';
|
||||
import { sendToTerminal } from '@/lib/events';
|
||||
import { encodeInput, encodeResize, parseServerFrame } from '@/lib/terminal-protocol';
|
||||
import type { TerminalFit } from './useTerminalFit';
|
||||
|
||||
// useTerminalSocket — owns the WebSocket lifecycle (connect / backoff
|
||||
// reconnect / manual reconnect), the terminal's onData + onResize handlers,
|
||||
// the bootstrap (init rAF: fit → /start → connect), the visibilitychange
|
||||
// reconnect-or-refit, the sendToTerminal subscription, and sticky Ctrl.
|
||||
//
|
||||
// It calls the fit hook's `getSize()` LAZILY (never captures a size value), so
|
||||
// every WS open / init / resize carries the currently-measured size, not a
|
||||
// stale one — preserving the read-latest contract.
|
||||
|
||||
export type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
|
||||
|
||||
interface SocketDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
fit: TerminalFit['fit'];
|
||||
getSize: TerminalFit['getSize'];
|
||||
setSize: TerminalFit['setSize'];
|
||||
}
|
||||
|
||||
export interface TerminalSocket {
|
||||
connState: ConnState;
|
||||
send: (text: string) => void;
|
||||
reconnect: () => void;
|
||||
ctrlArmed: boolean;
|
||||
armCtrl: () => void;
|
||||
}
|
||||
|
||||
export function useTerminalSocket({
|
||||
termRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
fit,
|
||||
getSize,
|
||||
setSize,
|
||||
}: SocketDeps): TerminalSocket {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const closedByUserRef = useRef(false);
|
||||
const reconnectFnRef = useRef<() => void>(() => {});
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
|
||||
// v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern).
|
||||
// ctrlArmedRef is read synchronously inside term.onData (per keystroke);
|
||||
// ctrlArmed state mirror exists so the Ctrl button can highlight in React.
|
||||
// Auto-disarms after 5s so a stray tap doesn't quietly mangle the next
|
||||
// keystroke minutes later.
|
||||
const [ctrlArmed, setCtrlArmed] = useState(false);
|
||||
const ctrlArmedRef = useRef(false);
|
||||
const ctrlTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const setCtrlArmedSync = useCallback((armed: boolean) => {
|
||||
ctrlArmedRef.current = armed;
|
||||
setCtrlArmed(armed);
|
||||
if (ctrlTimerRef.current) {
|
||||
clearTimeout(ctrlTimerRef.current);
|
||||
ctrlTimerRef.current = null;
|
||||
}
|
||||
if (armed) {
|
||||
ctrlTimerRef.current = setTimeout(() => {
|
||||
ctrlArmedRef.current = false;
|
||||
setCtrlArmed(false);
|
||||
ctrlTimerRef.current = null;
|
||||
}, 5000);
|
||||
}
|
||||
}, []);
|
||||
const armCtrl = useCallback(() => {
|
||||
setCtrlArmedSync(!ctrlArmedRef.current);
|
||||
}, [setCtrlArmedSync]);
|
||||
|
||||
// sendInput: write to the WS as a binary frame (server-side discriminator
|
||||
// routes binary to PTY, text to JSON control). Used by the hotkey bar and
|
||||
// the selection paste path.
|
||||
const send = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(encodeInput(text));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectFnRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
let disposed = false;
|
||||
closedByUserRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
||||
// boolab pattern: term.onResize fires synchronously inside term.resize()
|
||||
// and on init. We always update lastSizeRef so a fresh WS connect (or
|
||||
// reconnect) carries the current measured size; we send a resize frame
|
||||
// when the WS is open. No gating by an "is-started" flag — the WS itself
|
||||
// is the gate (it ignores writes when not open).
|
||||
term.onResize(({ cols, rows }) => {
|
||||
setSize(cols, rows);
|
||||
if (cols < 2 || rows < 1) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Keystrokes go out as a BINARY frame so the server can disambiguate them
|
||||
// from JSON control frames. TextEncoder is in every modern browser.
|
||||
//
|
||||
// Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next
|
||||
// single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z,
|
||||
// {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass
|
||||
// through untouched and disarm so a stuck arm doesn't poison them.
|
||||
term.onData((data) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
let out = data;
|
||||
if (ctrlArmedRef.current) {
|
||||
if (data.length === 1) {
|
||||
const c = data.charCodeAt(0);
|
||||
if (c >= 0x40 && c <= 0x7e) {
|
||||
out = String.fromCharCode(c & 0x1f);
|
||||
}
|
||||
}
|
||||
setCtrlArmedSync(false);
|
||||
}
|
||||
try {
|
||||
ws.send(encodeInput(out));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const { cols, rows } = getSize();
|
||||
return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`;
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (disposed || closedByUserRef.current) return;
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnState('disconnected');
|
||||
return;
|
||||
}
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!;
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (disposed || closedByUserRef.current) return;
|
||||
const existing = wsRef.current;
|
||||
if (existing && existing.readyState !== WebSocket.CLOSED) return;
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl());
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
if (disposed) return;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setConnState('open');
|
||||
// boolab pattern: send our currently-measured size as a resize frame
|
||||
// on every WS open. Server's PTY adopts it immediately; no race
|
||||
// window between attach and resize.
|
||||
const { cols, rows } = getSize();
|
||||
if (cols >= 2 && rows >= 1) {
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (e) => {
|
||||
const t = termRef.current;
|
||||
if (!t) return;
|
||||
if (typeof e.data === 'string') {
|
||||
const frame = parseServerFrame(e.data);
|
||||
if (frame?.type === 'init') {
|
||||
// boolab pattern: wipe any pre-attach buffer state, then the
|
||||
// server's next frame (a single binary capture-pane replay)
|
||||
// will paint the current tmux pane state in cleanly. Re-send
|
||||
// our measured size in case the server's init size (from the
|
||||
// WS query string) is stale relative to a fit that landed
|
||||
// between opening and now.
|
||||
try {
|
||||
t.clear();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const { cols, rows } = getSize();
|
||||
if (cols >= 2 && rows >= 1) {
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame?.type === 'exit') {
|
||||
t.write(`\r\n\x1b[2m[process exited with code ${frame.code}]\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
t.write(e.data);
|
||||
} else {
|
||||
t.write(new Uint8Array(e.data as ArrayBuffer));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (disposed) return;
|
||||
wsRef.current = null;
|
||||
if (closedByUserRef.current) return;
|
||||
setConnState('reconnecting');
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
// close fires after; let close own the retry.
|
||||
});
|
||||
}
|
||||
|
||||
function manualReconnect(): void {
|
||||
if (disposed) return;
|
||||
closedByUserRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setConnState('reconnecting');
|
||||
setTimeout(connect, 50);
|
||||
}
|
||||
reconnectFnRef.current = manualReconnect;
|
||||
|
||||
// Init: rAF-defer one frame so xterm's render service can populate
|
||||
// _renderService.dimensions, then fit → /start (idempotent, sizes
|
||||
// the tmux session) → connect WS. The WS handler also ensureSession's
|
||||
// as belt-and-suspenders, so a /start failure is non-fatal.
|
||||
const initRaf = requestAnimationFrame(() => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
const { cols, rows } = getSize();
|
||||
api.terminals
|
||||
.start(sessionId, paneId, cols, rows)
|
||||
.catch(() => {
|
||||
/* WS handler will ensureSession itself — non-fatal */
|
||||
})
|
||||
.finally(() => {
|
||||
if (disposed) return;
|
||||
connect();
|
||||
});
|
||||
});
|
||||
|
||||
const onVis = (): void => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
||||
manualReconnect();
|
||||
} else {
|
||||
// Refit in case the layout changed while hidden. Single rAF so the
|
||||
// measurement runs after the tab's first composited frame back in
|
||||
// the foreground.
|
||||
requestAnimationFrame(() => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||
if (pane_id !== paneId) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||
try {
|
||||
ws.send(encodeInput(payload));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
closedByUserRef.current = true;
|
||||
cancelAnimationFrame(initRaf);
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (ctrlTimerRef.current) {
|
||||
clearTimeout(ctrlTimerRef.current);
|
||||
ctrlTimerRef.current = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
unsubscribe();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000, 'unmount');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
// term.onResize / term.onData disposables are cleaned up by term.dispose()
|
||||
// (in the orchestrator's spine effect), matching the pre-split teardown.
|
||||
reconnectFnRef.current = () => {};
|
||||
};
|
||||
// fit/getSize/setSize are stable; sessionId/paneId drive reconnect-on-change
|
||||
// and term-handler rebinding after the orchestrator recreates the terminal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, fit, getSize, setSize]);
|
||||
|
||||
return { connState, send, reconnect, ctrlArmed, armCtrl };
|
||||
}
|
||||
26
apps/web/src/hooks/useArtifactDownload.ts
Normal file
26
apps/web/src/hooks/useArtifactDownload.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
export function useArtifactDownload(chatId: string, messageId: string, format: 'md' | 'html') {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(chatId, messageId, format);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { downloading, download };
|
||||
}
|
||||
@@ -69,10 +69,6 @@ export function recordUsage(
|
||||
notify();
|
||||
}
|
||||
|
||||
export function clearThroughput(chatId: string): void {
|
||||
if (entries.delete(chatId)) notify();
|
||||
}
|
||||
|
||||
// Periodic sweep: re-notify so stale entries fall off the UI when the
|
||||
// stream ends without a follow-up frame. Light — one timer for the whole app.
|
||||
const G = globalThis as Record<string, unknown>;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project } from '@/api/types';
|
||||
|
||||
export function useProjects() {
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.projects.list();
|
||||
setProjects(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to load projects');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const add = useCallback(
|
||||
async (body: { path: string; name?: string }) => {
|
||||
const created = await api.projects.add(body);
|
||||
await refresh();
|
||||
return created;
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.projects.remove(id);
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
return { projects, error, refresh, add, remove };
|
||||
}
|
||||
Reference in New Issue
Block a user