v1.10.4: booterm mobile UX — copy/paste, swipe-close, send-to-chat, search

- Long-press selection + floating menu (mobile + desktop right-click): Copy,
  Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss.
- Pane-header Paste button (📋) for iOS user-gesture clipboard read.
- Swipe-left-to-close on mobile pane pill with red "Close" overlay and
  translateX visual hint; spring-back below 80px threshold.
- Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror
  the existing terminalsRegistry pattern. ChatInput appends with newline
  separator on receive and focuses (no auto-send).
- Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with
  N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling.
- Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm
  also intercepts when focused. Browser native find passes through elsewhere.
- terminalsRegistry signature extended with openSearch + paste callbacks.

Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3
learnings (uid 1000 collision, libc match, two event buses, vite proxy order,
mobile pane URL sync, xterm canvas selection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 17:16:47 +00:00
parent 4d466c5710
commit ea9d261f0f
10 changed files with 892 additions and 43 deletions

View File

@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
@@ -51,9 +52,16 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
// an event appends the text to the current draft (with a newline separator
// when non-empty) and focuses — no auto-send.
chatId?: string;
chatLabel?: string;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -107,6 +115,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
});
}, []);
// v1.10.4: register this input in the chat-input registry so the terminal
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
// changes (e.g. rename) so the menu reflects the current name.
useEffect(() => {
if (!chatId) return;
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
textareaRef.current?.focus();
});
}, [chatId, chatLabel]);
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
// payload text to the current draft (with a newline separator if the
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
useEffect(() => {
if (!chatId) return;
return sendToChat.subscribe(({ chat_id, text }) => {
if (chat_id !== chatId) return;
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
// Put caret at end so the user can keep typing immediately.
const end = ta.value.length;
ta.selectionStart = ta.selectionEnd = end;
});
});
}, [chatId]);
function removeAttachment(id: string) {
setAttachments(prev => prev.filter(a => a.id !== id));
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
Bot,
ChevronDown,
@@ -31,6 +31,15 @@ interface Props {
onRenameChat: (chatId: string, name: string) => Promise<void>;
}
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
const SWIPE_CLOSE_PX = 80;
const SWIPE_VERTICAL_BAIL_PX = 30;
// Visual cap: pill translates left up to this much. Past this, dragX stays
// pinned so the user has a clear "release to close" indicator.
const SWIPE_VISUAL_CAP = 120;
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
@@ -70,11 +79,66 @@ export function MobileTabSwitcher({
const [open, setOpen] = useState(false);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
// in px. suppressClick latches when a swipe completes so the trailing click
// doesn't pop open the BottomSheet on the just-closed pane.
const [dragX, setDragX] = useState(0);
const swipeStart = useRef<{ x: number; y: number } | null>(null);
const swipeBailed = useRef(false);
const suppressClick = useRef(false);
const active = panes[activePaneIdx];
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
const activeChatId = paneActiveChatId(active);
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
swipeStart.current = { x: t.clientX, y: t.clientY };
swipeBailed.current = false;
setDragX(0);
}
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
if (!swipeStart.current || swipeBailed.current) return;
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - swipeStart.current.x;
const dy = t.clientY - swipeStart.current.y;
// Bail to scroll if vertical motion dominates before horizontal.
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
swipeBailed.current = true;
setDragX(0);
return;
}
// Only allow leftward drag (negative). Cap visual displacement.
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
setDragX(clamped);
}
function onPillTouchEnd(): void {
const finalDx = dragX;
swipeStart.current = null;
if (swipeBailed.current) {
setDragX(0);
return;
}
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
suppressClick.current = true;
// Reset dragX after the close so subsequent re-renders look right.
setDragX(0);
onRemovePane(activePaneIdx);
return;
}
setDragX(0);
}
function onPillClick(): void {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setOpen(true);
}
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
const longPress = useLongPress(({ clientX, clientY, target }) => {
@@ -113,17 +177,39 @@ export function MobileTabSwitcher({
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
aria-label="Switch pane"
<div
className="flex-1 relative min-w-0"
onTouchStart={onPillTouchStart}
onTouchMove={onPillTouchMove}
onTouchEnd={onPillTouchEnd}
onTouchCancel={onPillTouchEnd}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
because the actionable affordance is the swipe, not this label. */}
<div
aria-hidden="true"
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
>
Close
</div>
<button
type="button"
onClick={onPillClick}
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
aria-label="Switch pane"
style={{
transform: `translateX(${dragX}px)`,
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
}}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
</div>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
<ul className="px-2 py-2 space-y-1">

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
@@ -238,6 +239,23 @@ export function Workspace({
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
outside direct user gestures. A real button click IS a
gesture, so this works where keystroke-driven paste may
not on iOS. The action lives in TerminalPane behind the
registry's paste() callback. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
terminalsRegistry.get(pane.id)?.paste();
}}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Paste from clipboard"
title="Paste from clipboard"
>
<Clipboard size={12} />
</button>
{panes.length > 1 && (
<button
type="button"
@@ -245,7 +263,7 @@ export function Workspace({
e.stopPropagation();
removePane(idx);
}}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close terminal pane"
title="Close terminal pane"
>

View File

@@ -196,6 +196,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
onSlashCommand={handleSlashCommand}
chatId={chatId}
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
/>
</div>
</div>

View File

@@ -1,12 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { SearchAddon } from 'xterm-addon-search';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css';
import { RefreshCw } from 'lucide-react';
import { ChevronDown, ChevronUp, RefreshCw, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
import {
chatInputsRegistry,
sendToChat,
sendToTerminal,
terminalsRegistry,
type ChatInputRegistration,
} from '@/lib/events';
interface Props {
sessionId: string;
@@ -54,29 +61,103 @@ const XTERM_THEME = {
const XTERM_STYLE_OVERRIDES = `
.xterm { width: 100% !important; height: 100% !important; }
.xterm .xterm-screen { width: 100% !important; }
/* v1.10.4 gap fix: hide overflow (was: auto) to eliminate scrollbar gutter
* that FitAddon's proposeDimensions still accounts for. Transparent bg lets
* the host's TERM_BG show through any sub-cell rounding strip. */
.xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
background-color: transparent !important;
}
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
.xterm .xterm-viewport::-webkit-scrollbar { width: 0 !important; height: 0 !important; display: none !important; }
`;
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
const MAX_RECONNECT_ATTEMPTS = 3;
// v1.10.4: long-press timing for touch-driven selection. 500ms is the common
// "long-press" threshold; 10px is the dead-zone before we treat the gesture
// as a scroll/swipe instead.
const LONG_PRESS_MS = 500;
const LONG_PRESS_TOLERANCE_PX = 10;
// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
// is internal. We try it first and fall back to (container px / term.cols).
// The fallback overcounts because xterm reserves the right edge for the
// scrollbar (hidden, but the cells still respect the reserved px).
function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
// 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 };
}
const rect = container.getBoundingClientRect();
return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
}
// Pointer → buffer cell. Returns { col, bufferRow } where bufferRow is the
// absolute row in the scrollback buffer (i.e. viewportY-offset applied), so
// the result is stable across scroll. Clamped to valid ranges.
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 };
}
// Word boundary on a buffer line. Letters/digits/_/-/./~/$ count as word
// chars (path-friendly); everything else is a separator. matchAll keeps the
// scan purely iterator-based — no manual cursor needed.
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;
}
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 searchRef = useRef<SearchAddon | null>(null);
const reconnectRef = useRef<() => void>(() => {});
const [connState, setConnState] = useState<ConnState>('connecting');
// v1.10.4: floating menu state. Positioned in client coords.
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
// Forces the floating menu's chat list to re-read chatInputsRegistry when
// it's actually opened — keeps the registry-subscription tick local.
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
// Refs over state so the long-living useEffect can call the latest setters
// without re-running the effect (which would tear down xterm + WS).
const setMenuRef = useRef(setMenu);
setMenuRef.current = setMenu;
const setSearchOpenRef = useRef(setSearchOpen);
setSearchOpenRef.current = setSearchOpen;
const setChatInputsRef = useRef(setChatInputs);
setChatInputsRef.current = setChatInputs;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// TS doesn't preserve the null-narrowing across nested function bodies
// below (onTouchStart, etc.) because container is a closure capture. Bind
// a narrowed-type local that the inner closures can reference directly.
const ctr: HTMLDivElement = container;
let disposed = false;
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -97,7 +178,10 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
termRef.current = term;
const fit = new FitAddon();
fitRef.current = fit;
const search = new SearchAddon();
searchRef.current = search;
term.loadAddon(fit);
term.loadAddon(search);
term.loadAddon(new WebLinksAddon());
term.open(container);
try {
@@ -125,17 +209,38 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
});
}
// Shared paste path used by Cmd-V handler, the floating menu's "Paste"
// item, and the pane-header Paste button (via terminalsRegistry).
function pasteFromClipboard(): 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;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
})
.catch(() => {
toast.error('Paste blocked — long-press input area instead');
});
}
// 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
// v1.10.4: Cmd/Ctrl-F → open search bar over terminal, 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';
const isF = e.key === 'f' || e.key === 'F';
if (isC) {
if (term.hasSelection()) {
const sel = term.getSelection();
@@ -151,16 +256,14 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
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');
});
pasteFromClipboard();
return false;
}
if (isF) {
// Cmd/Ctrl-F when xterm has focus → open search. The Session-level
// shortcut handles the case where it doesn't, but xterm intercepts
// keys when focused so we need this binding too.
setSearchOpenRef.current(true);
return false;
}
return true;
@@ -287,13 +390,126 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
};
document.addEventListener('visibilitychange', onVis);
const unregister = terminalsRegistry.register(paneId, label, () => {
try {
term.focus();
} catch {
/* ignore */
// v1.10.4: long-press selection + floating menu. Touch handlers live on
// the xterm host container; we don't preventDefault on touchmove unless
// we've entered selection mode, so vertical scroll-by-finger still works.
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 {
// Compute the lexicographic min/max of (row, col) so the user can drag
// up-left or down-right and still extend in the natural reading order.
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;
// Anchor the menu above the touch point. Slight upward offset so the
// user's finger doesn't cover it.
setMenuRef.current({ 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();
// Leave the menu visible after release; user dismisses via tap-outside.
inSelection = false;
}
function onTouchCancel(): void {
clearLp();
inSelection = false;
}
container.addEventListener('touchstart', onTouchStart, { passive: true });
container.addEventListener('touchmove', onTouchMove, { passive: false });
container.addEventListener('touchend', onTouchEnd, { passive: true });
container.addEventListener('touchcancel', onTouchCancel, { passive: true });
// Desktop right-click: open the same floating menu.
function onContextMenu(e: MouseEvent): void {
e.preventDefault();
setMenuRef.current({ x: e.clientX, y: e.clientY });
}
container.addEventListener('contextmenu', onContextMenu);
// Tap-outside dismiss for the floating menu. Pointerdown fires before
// any click handler inside the menu, so we re-check the target.
function onDocPointerDown(e: PointerEvent): void {
const t = e.target as Element | null;
if (t && t.closest('[data-term-menu]')) return;
setMenuRef.current(null);
}
document.addEventListener('pointerdown', onDocPointerDown);
const unregister = terminalsRegistry.register(
paneId,
label,
() => {
try {
term.focus();
} catch {
/* ignore */
}
},
() => setSearchOpenRef.current(true),
() => pasteFromClipboard(),
);
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
const ws = wsRef.current;
@@ -301,6 +517,10 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
const payload = text.endsWith('\n') ? text : `${text}\n`;
ws.send(payload);
});
setChatInputsRef.current(chatInputsRegistry.list());
const unsubChats = chatInputsRegistry.subscribe(() => {
setChatInputsRef.current(chatInputsRegistry.list());
});
api.terminals.start(sessionId, paneId).catch(() => {});
connect();
@@ -308,10 +528,18 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
return () => {
disposed = true;
document.removeEventListener('visibilitychange', onVis);
document.removeEventListener('pointerdown', onDocPointerDown);
container.removeEventListener('touchstart', onTouchStart);
container.removeEventListener('touchmove', onTouchMove);
container.removeEventListener('touchend', onTouchEnd);
container.removeEventListener('touchcancel', onTouchCancel);
container.removeEventListener('contextmenu', onContextMenu);
unsubscribe();
unsubChats();
unregister();
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
if (lpTimer !== null) clearTimeout(lpTimer);
ro.disconnect();
try {
wsRef.current?.close();
@@ -322,6 +550,7 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
term.dispose();
termRef.current = null;
fitRef.current = null;
searchRef.current = null;
reconnectRef.current = () => {};
};
}, [sessionId, paneId, label]);
@@ -344,6 +573,46 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
return () => cancelAnimationFrame(raf);
}, [active]);
// Floating menu actions. Each operates on termRef.current and clears menu.
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 (
<div
// v1.10.3 Issue 5: flex-1 + min-w-0 + self-stretch + w-full lets the
@@ -351,9 +620,6 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
// 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}
@@ -361,6 +627,27 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
style={{ touchAction: 'pan-y', background: TERM_BG }}
data-testid="terminal-pane"
/>
{searchOpen && (
<SearchBar
searchRef={searchRef}
theme={XTERM_THEME}
onClose={() => setSearchOpen(false)}
/>
)}
{menu && (
<FloatingMenu
x={menu.x}
y={menu.y}
hasSelection={hasSelection}
chatInputs={chatInputs}
onCopy={actCopy}
onPaste={actPaste}
onSelectAll={actSelectAll}
onSearch={actSearch}
onSendToChat={actSendToChat}
onDismiss={() => setMenu(null)}
/>
)}
{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" />
@@ -382,3 +669,321 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
</div>
);
}
// v1.10.4: shared floating menu used by both desktop right-click and mobile
// long-press. shadcn-style chrome: dark bg, subtle border, 14px text, 44px
// touch targets via padding. Tap-outside dismiss is wired in the parent's
// document pointerdown listener.
interface FloatingMenuProps {
x: number;
y: number;
hasSelection: boolean;
chatInputs: ChatInputRegistration[];
onCopy: () => void;
onPaste: () => void;
onSelectAll: () => void;
onSearch: () => void;
onSendToChat: (chatId: string) => void;
onDismiss: () => void;
}
function FloatingMenu({
x,
y,
hasSelection,
chatInputs,
onCopy,
onPaste,
onSelectAll,
onSearch,
onSendToChat,
onDismiss,
}: FloatingMenuProps) {
const [submenu, setSubmenu] = useState(false);
// Clamp into viewport so the menu doesn't render off-screen on small
// viewports / near edges.
const MENU_W = 200;
const MENU_H = 220;
const left = Math.min(x, window.innerWidth - MENU_W - 8);
const top = Math.min(y, window.innerHeight - MENU_H - 8);
useEffect(() => {
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') onDismiss();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onDismiss]);
// Exactly one chat input registered → flat "Send to <N>" entry instead of
// a submenu (per v1.10.4 spec).
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
return (
<div
data-term-menu="1"
role="menu"
style={{
position: 'fixed',
top,
left,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
fontSize: 14,
minWidth: MENU_W,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
zIndex: 50,
color: '#d6deeb',
}}
>
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
<MenuItem onClick={onPaste}>Paste</MenuItem>
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
<MenuItem onClick={onSearch}>Search</MenuItem>
{flatChat && (
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
Send to {flatChat.label}
</MenuItem>
)}
{chatInputs.length > 1 && (
<div>
<button
type="button"
disabled={!hasSelection}
onClick={() => setSubmenu((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: hasSelection ? '#d6deeb' : '#575656',
cursor: hasSelection ? 'pointer' : 'not-allowed',
borderRadius: 6,
textAlign: 'left',
}}
>
<span>Send to chat</span>
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{submenu && hasSelection && (
<div style={{ paddingLeft: 8 }}>
{chatInputs.map((c) => (
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
{c.label}
</MenuItem>
))}
</div>
)}
</div>
)}
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
</div>
);
}
function MenuItem({
children,
onClick,
disabled = false,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: disabled ? '#575656' : '#d6deeb',
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
borderRadius: 6,
fontSize: 14,
}}
onMouseEnter={(ev) => {
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
}}
onMouseLeave={(ev) => {
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{children}
</button>
);
}
// v1.10.4: floating search bar pinned to the top of the terminal pane. Uses
// SearchAddon.findNext / findPrevious. Incremental search on each keystroke
// keeps the highlighted match in sync.
interface SearchBarProps {
searchRef: React.MutableRefObject<SearchAddon | null>;
theme: typeof XTERM_THEME;
onClose: () => void;
}
function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
const [q, setQ] = useState('');
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// onDidChangeResults fires whenever the SearchAddon's decoration set
// updates. We mirror it into local state for the "N of M" indicator.
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
setCounts({ idx: resultIndex, total: resultCount });
});
return () => sub.dispose();
}, [searchRef]);
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
if (q.length === 0) {
addon.clearDecorations?.();
setCounts({ idx: -1, total: 0 });
return;
}
addon.findNext(q, {
incremental: true,
decorations: {
matchBackground: theme.selectionBackground,
matchOverviewRuler: theme.cursor,
activeMatchBackground: theme.cursor,
activeMatchColorOverviewRuler: theme.cursor,
},
});
}, [q, searchRef, theme]);
function findNext(): void {
if (!q) return;
searchRef.current?.findNext(q);
}
function findPrev(): void {
if (!q) return;
searchRef.current?.findPrevious(q);
}
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
if (ev.key === 'Escape') {
ev.preventDefault();
onClose();
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
if (ev.shiftKey) findPrev();
else findNext();
}
}
return (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
display: 'flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 40,
}}
>
<input
ref={inputRef}
value={q}
onChange={(ev) => setQ(ev.target.value)}
onKeyDown={onKey}
placeholder="Search…"
style={{
background: 'transparent',
border: 0,
outline: 'none',
color: '#d6deeb',
padding: '8px 8px',
fontSize: 13,
width: 160,
minHeight: 36,
}}
/>
{q.length > 0 && (
<span
style={{
fontSize: 11,
color: counts.total === 0 ? '#ef5350' : '#575656',
minWidth: 56,
textAlign: 'right',
padding: '0 4px',
whiteSpace: 'nowrap',
}}
>
{counts.total === 0
? 'No match'
: counts.idx === -1
? `${counts.total}+`
: `${counts.idx + 1} of ${counts.total}`}
</span>
)}
<button
type="button"
onClick={findPrev}
aria-label="Previous match"
title="Previous (Shift+Enter)"
style={iconBtnStyle}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={findNext}
aria-label="Next match"
title="Next (Enter)"
style={iconBtnStyle}
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close search"
title="Close (Esc)"
style={iconBtnStyle}
>
<X size={16} />
</button>
</div>
);
}
const iconBtnStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
background: 'transparent',
border: 0,
color: '#d6deeb',
cursor: 'pointer',
borderRadius: 6,
};