Files
kandev/apps/web/components/task/use-passthrough-terminal.ts
T
José Almeida 046ac5c1b6 feat(web): add Ctrl+F search to session, plan, and terminal panels (#686)
* feat(backend): add session-scoped message search endpoint

Adds SearchMessages across repository/service/handler layers and a new
WebSocket action "message.search". Returns lightweight hits with a
60-char snippet around the first match. Session-scoped only: results
never leak across sessions. Uses dialect-aware LIKE/ILIKE so it works
on SQLite today and Postgres later.

* feat(web): add shared panel search bar and Ctrl+F hook

Introduces the reusable PanelSearchBar overlay and usePanelSearch hook
used by the per-panel search features. The hook listens on the window
and fires only when focus is within the panel root, preventDefaults the
browser find-in-page, and wires Esc to close. PanelRoot becomes a
forwardRef so panel components can expose their root element to the
hook. Adds SHORTCUTS.FIND_IN_PANEL plus shared highlight/flash styles.

* feat(web): add Ctrl+F search to terminal panels

Mounts the xterm addon-search on both passthrough and shell terminals,
exposing next/prev plus case-sensitivity and regex toggles. Invalid
regex surfaces an inline error without firing the search. Because xterm
consumes keystrokes before React, the Ctrl/Cmd+F intercept is added to
attachCustomKeyEventHandler so the shortcut opens the bar from inside
the terminal. Results counter comes from onDidChangeResults; decorations
clear on close.

* feat(web): add Ctrl+F search to plan panel

Adds a custom TipTap extension that builds a ProseMirror DecorationSet
over the document and exposes setPlanSearchQuery / planSearchNext /
planSearchPrev / clearPlanSearch commands. The current match gets an
extra class and scrolls into view. Match count is read from the plugin
state via transaction subscription, so edits to the document update the
counter automatically. Find-only — no replace.

* feat(web): add Ctrl+F search to session chat panel

Wires the new backend message.search action into a debounced client
hook and renders a hits dropdown under the search bar, with the query
highlighted in each snippet. Clicking a hit scrolls the message into
view and flashes it. When the target message is older than what is
currently loaded, the hook loops through loadOlder pages until the
message renders, making search work across lazy-loaded history without
needing to scroll-and-wait manually.

* fix(web): lazy-load xterm addon-search to avoid SSR crash

addon-search is published as a UMD bundle that references `self` at
module load. Statically importing it breaks the Next.js static
prerender of /. Deferring the import to an effect keeps the module out
of the SSR graph without changing runtime behaviour.

* fix(web): propagate plan editor via state so plan-search hook re-runs

The plan-search hook subscribed to editor transactions inside a
useEffect that depended on a ref object. Because ref.current changes do
not trigger effect re-runs, if the Editor instance was not yet ready
when the hook first mounted, the transaction handler never attached and
the match counter stayed at 0 / 0. Tracking the editor via state (in
addition to the ref) causes the deps to change when the Editor becomes
available, so the handler attaches and the counter updates.

* feat(web): allow overriding message list renderer via ?renderer= query

Exposes the "native" / "virtuoso" choice at runtime so E2E coverage can
exercise both paths without a redeploy or code change. Defaults to the
existing "native" strategy when no override is present.

* test(web): add Playwright coverage for panel-scoped Ctrl+F search

Adds an e2e/tests/search/ suite with five specs:

- panel-search-shared: cross-panel contract (Ctrl+F opens, autofocus,
  Esc closes, close button, state reset on reopen) parametrised over
  session, plan, and terminal.
- terminal-search: xterm addon-search integration — key intercept
  without leaking "f" to the PTY, case + regex toggles (incl. invalid
  regex error), Esc does not reach the shell, backspace adjusts count.
- plan-search: TipTap plugin — highlight count, exactly one current
  match, counter, Enter / Shift+Enter wrapping, close clears
  decorations, no case/regex toggles in plan bar.
- session-search: WebSocket-backed hits list, "No matches" state,
  click-to-flash, cross-session isolation, close, and virtuoso renderer
  coverage via the new ?renderer= override.
- panel-focus-routing: bar mounts inside the focused panel only.

Three tests capture GIFs for the PR description via prCapture when
CAPTURE_PR_ASSETS is set (one per panel). Helpers live in
e2e/helpers/panel-search.ts and e2e/helpers/seed-session-messages.ts;
SessionPage gains search-bar locators.

* fix(web): prevent plan-search update loop on spurious editor transactions

TipTap fires `transaction` on every cursor move/click. The match-info
handler allocated a fresh `{current, total}` object each time, so React
always saw new identity, re-rendered, and could cascade with downstream
effects (e.g. `rehydrateCommentMarks`) into a "Maximum update depth
exceeded" loop. Use a functional setState with equality bail-out so the
handler only schedules a render when the counter actually changes.

* fix(web): retry terminal search once xterm addon-search finishes loading

The SearchAddon is dynamic-imported and was stored in a ref. The
query→findNext effect called `addonRef.current?.findNext(...)` — a
silent no-op if the addon wasn't loaded yet — and its deps didn't
include addon readiness, so a query typed before the import resolved
was dropped forever. Track the addon via state so the search effect
re-runs when it becomes available (and re-binds cleanly if the terminal
is ever re-created). Also deduplicate match-info updates via functional
setState + equality check.

* fix(web): route Ctrl+F to the last-clicked panel when focus is ambient

The chat panel's message list has no focus-capturing child, so clicking
on a message left `document.activeElement === <body>` and the existing
`isFocusWithin` gate silently fell through to the browser's native find.
Fix with two complementary mechanisms:

- `usePanelSearch`: track the most-recently-clicked panel via a
  window-level pointerdown capture listener. When focus is ambient
  (body/html), that panel claims Ctrl+F. Window-level capture dodges
  any descendant `stopPropagation` that would skip a container-level
  listener.
- Chat panel root: tabIndex=-1 + onMouseDown that focuses the root when
  the click target isn't already focusable. Normal path matches how
  plan/terminal already work (TipTap/xterm capture focus on click).

* feat(web): show agent profile name and logo in session search hits

The hit list showed uppercase "AGENT"/"USER" with vivid blue/green
colors that clashed with the app's muted metadata aesthetic. Replace
with the actual author identity:

- Agent hits render the profile name (falling back to "Agent") with
  the profile's <AgentLogo> inline — same mechanism session tabs use.
- User hits render "You" with a subtle `text-primary/80` tint that
  echoes the user bubble's `bg-primary/30`, keeping a light color
  differentiator without reintroducing loud blue/green.

Added `useSessionAgentIdentity` (plus a `useSessionAgentProfile` helper)
that reads from the store via two primitive selectors — the id, then
the profile — and composes the final object inside `useMemo`. A naive
single selector returning `{label, name}` triggers the "getSnapshot
should be cached" infinite loop in `useSyncExternalStore`.

* style: apply prettier formatting to search files

* fix(web): inline resolveStrategy in useMemo to satisfy react-hooks/use-memo

* fix(backend): escape LIKE metacharacters in message search

User query of `%` previously built LIKE pattern `%%` which matched every
message in the session; `file_name` also matched `filename`, `file name`,
etc. Escape the three LIKE metacharacters (`\`, `%`, `_`) before wrapping
the pattern, and declare `ESCAPE '\'` on the clause. Add regression tests
covering wildcard literals and the literal-percent happy path.

* fix(backend): slice search snippet in rune space to keep UTF-8 valid

`buildSnippet` indexed and sliced on byte offsets, so content containing
multi-byte characters (emoji, CJK, accented letters) could be cut
mid-rune — `encoding/json` then replaced the invalid bytes with `\ufffd`
in the websocket response. Switch to rune slicing and per-rune
case-folding via `unicode.ToLower`; this also avoids the
`strings.ToLower` byte-length-change pitfall (e.g. Turkish İ, German ß).
Add a handler test covering emoji, CJK, and large multi-byte content.

* fix(web): cancel backfill loop when user clicks a newer session hit

`setActiveHit` awaits up to 40 `loadOlder()` iterations when the target
message isn't in the DOM. If the user clicked a second hit while those
awaits were in flight, the in-progress loop kept running and eventually
scrolled to the original target, overriding the user's intent. Gate the
loop on a generation counter so only the most recent call's backfill
drives the scroll.

* fix(web): gate plan-search scroll side-effect on dispatch

TipTap invokes commands with `dispatch === undefined` during `can()`
checks to test applicability (e.g. before enabling a menu item). The
next/prev commands called `scrollToMatch` outside the `if (dispatch)`
guard, so a dry-run invocation would scroll the editor to a match the
user didn't navigate to. Move the scroll inside the dispatch block.

* fix(web): address review nits on search UX

- formatTime now checks Number.isNaN(d.getTime()) before formatting;
  Invalid Date doesn't throw, so the catch branch was unreachable and
  hits with a malformed created_at rendered the literal "Invalid Date".
- prefers-reduced-motion now silences the .search-flash animation
  alongside the other custom keyframe animations.
- planScript escapes `title` symmetrically with `content` so callers
  passing arbitrary strings don't produce malformed JSON.

* fix(web): fold description into opts to keep seedTask under param limit

`seedTask` had 6 parameters, violating the repo's 5-parameter cap and
failing CI lint. Fold `description` into the existing `opts` object and
update all callsites.

* test(web): add unit tests for useSessionSearch + remove redundant ternary

Cover the non-trivial hook logic called out in review: debounced fetch
coalescing, request-ID cancellation for superseded queries, null
sessionId short-circuit, and close() resets. Also simplify the
`matches.length ? 0 : 0` ternary flagged as a dead branch — both sides
were already 0.

* fix(web): exclude PanelRoot (tabindex=-1) from the focus short-circuit

`target.closest("[tabindex]")` was matching PanelRoot itself (which has
`tabIndex={-1}` so it can receive programmatic focus), causing the guard
to early-return and the `panelRef.focus()` call to never fire — defeating
the Ctrl+F focus-routing that this handler was added for.

Narrow the selector to `[tabindex]:not([tabindex='-1'])` so it only
short-circuits on genuinely interactive elements.

* fix(web): read isReadOnlyMode from live ref in shell-terminal key handler

`attachCustomKeyEventHandler` is called once and has no dispose hook, so
the inner handler keeps its initial closure forever. Other values
(sessionId, send, onFindInPanel) are read via `stateRef` so they stay
current, but `isReadOnlyMode` was captured by closure — if the prop ever
toggled mid-session, the Cmd+Arrow→Home/End path would continue firing
against a read-only terminal. Include it in the state ref for
consistency.

* fix(web): flatten doubly-nested seedTask opts in virtuoso spec; drop unused MODIFIER

The regex-based transform in 7152b936 over-nested one call in
session-search.spec.ts — `opts.description` received an object rather
than a string, so the virtuoso renderer test never received a proper
task description. Flatten it.

Also remove MODIFIER imports/declarations in terminal-search.spec.ts and
panel-search-shared.spec.ts that were left dangling behind `void` suppressors.

* fix(web): abort session-search backfill loop on close/unmount

Move reset logic into `close()` so the lifecycle is explicit (and avoids
the set-state-in-effect lint path the previous reset effect triggered).
Bump the `activeHitGenRef` generation counter on both `close()` and
unmount so an in-flight `setActiveHit` backfill can no longer issue
`loadOlder()` calls for a hidden UI.

Also extract `useDebouncedSearch`, `useSetActiveHit`, and a
`focusMessageElement` helper to keep `useSessionSearch` under the
100-line function cap.

* perf(web): skip PanelSearchBar debounce for session search

Session search already debounces the WS call inside `useDebouncedSearch`
(180ms), but the shared bar adds its own 150ms debounce on top — giving
a 330ms keystroke→request latency instead of the intended ~180ms.
Pass `debounceMs={0}` so the bar forwards queries immediately and the
hook-level debounce is the single source of timing truth.
2026-04-24 17:14:03 +01:00

627 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useCallback } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { AttachAddon } from "@xterm/addon-attach";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { getTerminalTheme } from "@/lib/theme/terminal-theme";
import { startReconnectLoop } from "./ws-reconnect";
import { matchesShortcut } from "@/lib/keyboard/utils";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import { exposeBufferReader, clearBufferReader } from "./terminal-buffer-reader";
// Debug flag - set to true to see detailed logs
const DEBUG = false;
export const log = (...args: unknown[]) => {
if (DEBUG) console.log("[PassthroughTerminal]", ...args);
};
// Minimum dimensions to prevent zero-size issues
export const MIN_WIDTH = 100;
export const MIN_HEIGHT = 100;
export type TerminalInitOptions = {
terminalRef: React.RefObject<HTMLDivElement | null>;
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
isInitializedRef: React.MutableRefObject<boolean>;
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
resizeTimeoutRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
webglAddonRef: React.MutableRefObject<WebglAddon | null>;
fitAndResize: (force?: boolean) => void;
onReady: () => void;
};
type TerminalKeyHandlerOptions = {
onToggleBottomTerminal?: () => void;
sendInput?: (data: string) => void;
/** Ref to keyboard shortcut overrides so the handler always reads the latest value. */
keyboardShortcutsRef?: React.MutableRefObject<StoredShortcutOverrides | undefined>;
/** Ref to handler called when Ctrl/Cmd+F is pressed inside the terminal. */
onFindInPanelRef?: React.MutableRefObject<(() => void) | undefined>;
};
function isCmdArrowLine(event: KeyboardEvent): boolean {
return (
event.type === "keydown" &&
event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(event.key === "ArrowLeft" || event.key === "ArrowRight")
);
}
function handleAppShortcuts(event: KeyboardEvent, options: TerminalKeyHandlerOptions): boolean {
if (matchesShortcut(event, SHORTCUTS.FIND_IN_PANEL)) {
event.preventDefault();
if (event.type === "keydown") options.onFindInPanelRef?.current?.();
return true;
}
if (
matchesShortcut(event, getShortcut("BOTTOM_TERMINAL", options.keyboardShortcutsRef?.current))
) {
event.preventDefault();
if (event.type === "keydown") options.onToggleBottomTerminal?.();
return true;
}
return false;
}
/** Handles app-level shortcuts and Cmd+Arrow→Home/End mapping for macOS. */
function createKeyEventHandler(options: TerminalKeyHandlerOptions) {
return (event: KeyboardEvent): boolean => {
if (handleAppShortcuts(event, options)) return false;
if (isCmdArrowLine(event)) {
event.preventDefault();
// Ctrl+A (0x01) = beginning-of-line, Ctrl+E (0x05) = end-of-line
// Works universally in bash/zsh emacs mode (the default)
const seq = event.key === "ArrowLeft" ? "\x01" : "\x05";
options.sendInput?.(seq);
return false;
}
return true;
};
}
/**
* Defer WebGL addon loading to the next animation frame so the initial
* synchronous work (Terminal + FitAddon + open) stays within the browser's
* frame budget.
*
* Skips WebGL on Firefox: its canvas fingerprinting protection silently
* poisons readback data, corrupting the glyph texture atlas.
*/
function deferWebGLAddon(refs: Pick<TerminalInitOptions, "xtermRef" | "webglAddonRef">) {
const isFirefox = typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent);
if (isFirefox) {
log("Skipping WebGL addon on Firefox (canvas fingerprinting protection)");
return;
}
requestAnimationFrame(() => {
const term = refs.xtermRef.current;
if (!term || refs.webglAddonRef.current) return;
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
log("WebGL context lost");
webglAddon.dispose();
refs.webglAddonRef.current = null;
});
term.loadAddon(webglAddon);
refs.webglAddonRef.current = webglAddon;
log("WebGL addon loaded");
} catch (e) {
log("WebGL failed, using canvas:", e);
}
});
}
function initTerminalInstance(
termContainer: HTMLDivElement,
refs: Pick<
TerminalInitOptions,
| "xtermRef"
| "fitAddonRef"
| "isInitializedRef"
| "lastDimensionsRef"
| "webglAddonRef"
| "resizeTimeoutRef"
>,
fitAndResize: (force?: boolean) => void,
options: {
linkHandler?: (event: MouseEvent, uri: string) => void;
fontFamily?: string;
fontSize?: number;
} & TerminalKeyHandlerOptions,
) {
if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined;
refs.isInitializedRef.current = true;
log("Creating terminal");
const terminal = new Terminal({
allowProposedApi: true,
cursorBlink: true,
disableStdin: false,
convertEol: false,
scrollOnUserInput: true,
scrollback: 5000,
fontSize: options.fontSize || 13,
fontFamily: options.fontFamily || 'Menlo, Monaco, "Courier New", monospace',
macOptionIsMeta: true,
theme: getTerminalTheme(termContainer),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
const unicode11Addon = new Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = "11";
const webLinksAddon = new WebLinksAddon(options.linkHandler);
terminal.loadAddon(webLinksAddon);
terminal.open(termContainer);
terminal.attachCustomKeyEventHandler(createKeyEventHandler(options));
try {
fitAddon.fit();
refs.lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows };
} catch {
/* fit failed */
}
refs.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon;
deferWebGLAddon(refs);
exposeBufferReader(termContainer, terminal);
const handleResize = () => {
const rect = termContainer.getBoundingClientRect();
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
log("Skipping resize - too small");
return;
}
if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current);
refs.resizeTimeoutRef.current = setTimeout(() => {
fitAndResize();
}, 100);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(termContainer);
return () => {
log("Terminal cleanup");
if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current);
resizeObserver.disconnect();
if (refs.webglAddonRef.current) {
refs.webglAddonRef.current.dispose();
refs.webglAddonRef.current = null;
}
terminal.dispose();
clearBufferReader(termContainer);
refs.xtermRef.current = null;
refs.fitAddonRef.current = null;
refs.isInitializedRef.current = false;
refs.lastDimensionsRef.current = { cols: 0, rows: 0 };
};
}
type TerminalInitHookOptions = TerminalInitOptions &
TerminalKeyHandlerOptions & {
linkHandler?: (event: MouseEvent, uri: string) => void;
fontFamily?: string;
fontSize?: number;
};
export function useTerminalInit({
terminalRef,
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
fitAndResize,
onReady,
linkHandler,
fontFamily,
fontSize,
onToggleBottomTerminal,
sendInput,
keyboardShortcutsRef,
onFindInPanelRef,
}: TerminalInitHookOptions) {
const refs = {
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
};
useEffect(() => {
log("Terminal init effect");
const container = terminalRef.current;
if (!container) {
log("No container ref");
return;
}
if (isInitializedRef.current) {
log("Already initialized");
return;
}
const tryInit = () => {
if (isInitializedRef.current) return true;
const rect = container.getBoundingClientRect();
log("Init check: dimensions", rect.width, "x", rect.height);
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
initTerminalInstance(container, refs, fitAndResize, {
linkHandler,
fontFamily,
fontSize,
onToggleBottomTerminal,
sendInput,
keyboardShortcutsRef,
onFindInPanelRef,
});
onReady();
return true;
}
return false;
};
// If the container already has dimensions, init immediately.
if (tryInit()) {
// Already initialized — cleanup handled by initTerminalInstance's return
} else {
// Container is 0×0 (e.g. terminal is in a background dockview tab).
// Use a ResizeObserver to wait until the container becomes visible
// instead of polling with rAF and force-initializing at 0×0
// (which causes black backgrounds since CSS vars can't be read).
log("Container not visible, waiting via ResizeObserver");
const observer = new ResizeObserver(() => {
if (tryInit()) {
observer.disconnect();
}
});
observer.observe(container);
return () => {
observer.disconnect();
};
}
const resizeTimeout = resizeTimeoutRef;
const webgl = webglAddonRef;
const xterm = xtermRef;
const fitAddon = fitAddonRef;
return () => {
log("Effect cleanup");
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (webgl.current) {
webgl.current.dispose();
webgl.current = null;
}
if (xterm.current) {
xterm.current.dispose();
xterm.current = null;
}
fitAddon.current = null;
isInitializedRef.current = false;
lastDimensionsRef.current = { cols: 0, rows: 0 };
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fitAndResize]);
}
export type WebSocketConnectionOptions = {
taskId: string | null;
sessionId: string | null | undefined;
canConnect: boolean;
isTerminalReady: boolean;
fitAndResize: (force?: boolean) => void;
wsBaseUrl: string;
mode: "agent" | "shell";
terminalId: string | undefined;
label?: string;
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
wsRef: React.MutableRefObject<WebSocket | null>;
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
onConnected: () => void;
};
function buildWsUrl(
wsBaseUrl: string,
sessionId: string,
mode: "agent" | "shell",
terminalId: string | undefined,
label?: string,
): string {
let wsUrl =
mode === "agent"
? `${wsBaseUrl}/terminal/${sessionId}?mode=agent`
: `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
if (label) wsUrl += `&label=${encodeURIComponent(label)}`;
return wsUrl;
}
type ConnectWebSocketOptions = {
sessionId: string;
wsBaseUrl: string;
mode: "agent" | "shell";
terminalId: string | undefined;
label: string | undefined;
terminal: Terminal;
fitAndResize: (force?: boolean) => void;
wsRef: React.MutableRefObject<WebSocket | null>;
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
isMountedCheck: () => boolean;
onTimeout: (id: ReturnType<typeof setTimeout>) => void;
onConnected: () => void;
onSocketClose: (event: CloseEvent) => void;
};
function connectWebSocket({
sessionId,
wsBaseUrl,
mode,
terminalId,
label,
terminal,
fitAndResize,
wsRef,
attachAddonRef,
isMountedCheck,
onTimeout,
onConnected,
onSocketClose,
}: ConnectWebSocketOptions) {
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CLOSING
) {
wsRef.current.close();
}
wsRef.current = null;
}
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
log("Connecting to", wsUrl, { mode, terminalId, label });
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
if (!isMountedCheck()) {
ws.close();
return;
}
log("WebSocket connected");
const attachAddon = new AttachAddon(ws, { bidirectional: true });
terminal.loadAddon(attachAddon);
attachAddonRef.current = attachAddon;
onConnected();
// Send initial resize (forced) so the backend knows our terminal dimensions,
// then one deferred resize to catch layout settling + force a full redraw.
// The WebGL renderer can become stale when the container transitions through
// 0×0 (portal system detach/reattach), so we must explicitly refresh.
requestAnimationFrame(() => {
if (isMountedCheck() && ws.readyState === WebSocket.OPEN) {
fitAndResize(true);
}
});
const settleTimeout = setTimeout(() => {
if (!isMountedCheck()) return;
if (ws.readyState === WebSocket.OPEN) {
fitAndResize(true);
}
// Force full redraw — WebGL canvas may be stale after portal 0×0 transition
terminal.refresh(0, terminal.rows - 1);
}, 500);
onTimeout(settleTimeout);
};
ws.onclose = (event) => {
log("WebSocket closed:", event.code, event.reason);
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
onSocketClose(event);
};
ws.onerror = (error) => {
log("WebSocket error:", error);
};
}
// reconnectDelayMs and startReconnectLoop are in ws-reconnect.ts
// Re-export reconnectDelayMs for tests that import from this module.
export { reconnectDelayMs } from "./ws-reconnect";
export function useWebSocketConnection({
taskId,
sessionId,
canConnect,
isTerminalReady,
fitAndResize,
wsBaseUrl,
mode,
terminalId,
label,
xtermRef,
fitAddonRef,
wsRef,
attachAddonRef,
onConnected,
}: WebSocketConnectionOptions) {
useEffect(() => {
log("WebSocket effect:", {
taskId,
sessionId,
mode,
terminalId,
canConnect,
isTerminalReady,
hasTerminal: !!xtermRef.current,
});
if (!taskId || !sessionId || !canConnect || !isTerminalReady) {
log("WebSocket effect: early return", { taskId, sessionId, canConnect, isTerminalReady });
return;
}
if (!xtermRef.current || !fitAddonRef.current) {
log("Terminal not ready for WebSocket (refs missing despite isTerminalReady)");
return;
}
const terminal = xtermRef.current;
// Clear the terminal buffer before reconnecting. The chat panel is global
// (not session-scoped), so the same xterm instance persists across session
// switches. Without this reset, the previous session's PTY output leaks
// into the new session's scrollback.
log("Resetting terminal buffer for session", sessionId);
terminal.reset();
const stopReconnectLoop = startReconnectLoop({
sessionId,
wsBaseUrl,
mode,
terminalId,
label,
terminal,
fitAndResize,
wsRef,
attachAddonRef,
onConnected,
connectWebSocket,
});
return () => {
log("WebSocket cleanup");
stopReconnectLoop();
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
// Only close if the connection is actually open or has completed
// opening. Closing a CONNECTING WebSocket triggers a browser warning
// ("WebSocket is closed before the connection is established").
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CLOSING
) {
wsRef.current.close();
}
wsRef.current = null;
}
};
}, [
taskId,
sessionId,
canConnect,
isTerminalReady,
fitAndResize,
wsBaseUrl,
mode,
terminalId,
label,
xtermRef,
fitAddonRef,
wsRef,
attachAddonRef,
onConnected,
]);
}
function buildResizeBuffer(cols: number, rows: number): Uint8Array {
const json = JSON.stringify({ cols, rows });
const encoder = new TextEncoder();
const jsonBytes = encoder.encode(json);
const buffer = new Uint8Array(1 + jsonBytes.length);
buffer[0] = 0x01;
buffer.set(jsonBytes, 1);
return buffer;
}
export function useSendResize(wsRef: React.MutableRefObject<WebSocket | null>) {
return useCallback(
(cols: number, rows: number) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
log("sendResize: WebSocket not ready", ws?.readyState);
return;
}
if (cols <= 0 || rows <= 0) {
log("sendResize: invalid dimensions", cols, rows);
return;
}
log("sendResize:", cols, "x", rows);
ws.send(buildResizeBuffer(cols, rows));
},
[wsRef],
);
}
/** Returns a stable ref whose `.current` sends raw string data to the PTY via WebSocket. */
export function useSendInput(wsRef: React.MutableRefObject<WebSocket | null>) {
return useCallback(
(data: string) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
},
[wsRef],
);
}
export type FitAndResizeOptions = {
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
terminalRef: React.RefObject<HTMLDivElement | null>;
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
sendResize: (cols: number, rows: number) => void;
};
export function useFitAndResize({
xtermRef,
fitAddonRef,
terminalRef,
lastDimensionsRef,
sendResize,
}: FitAndResizeOptions) {
return useCallback(
(force = false) => {
const terminal = xtermRef.current;
const fitAddon = fitAddonRef.current;
const container = terminalRef.current;
if (!terminal || !fitAddon || !container) {
log("fitAndResize: missing refs");
return;
}
const rect = container.getBoundingClientRect();
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
log("fitAndResize: container too small, skipping");
return;
}
try {
fitAddon.fit();
log("fitAndResize: fit done", terminal.cols, "x", terminal.rows);
} catch (e) {
log("fitAndResize: fit failed", e);
return;
}
const { cols, rows } = terminal;
const last = lastDimensionsRef.current;
const changed = cols !== last.cols || rows !== last.rows;
const wasZero = last.cols === 0 && last.rows === 0;
if (force || changed) {
lastDimensionsRef.current = { cols, rows };
sendResize(cols, rows);
}
// Force full redraw when transitioning from uninitialized/zero dimensions —
// the WebGL canvas may be stale after the container was at 0×0 (portal moves).
if (wasZero || changed) {
terminal.refresh(0, terminal.rows - 1);
}
},
[xtermRef, fitAddonRef, terminalRef, lastDimensionsRef, sendResize],
);
}