From 7317eb988ff977b3924f5eb722f0b1a4acf2e356 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 22 Feb 2026 11:45:41 -0800 Subject: [PATCH] feat: fix mobile scroll, add mode toggle and DPad scroll mode Touch scrolling was broken on all tmux terminals because mouse mode was set per-session but grouped sessions (agentboard-ws-*) inherited the global setting (mouse off). Changed to set-option -g so all sessions get mouse mode. Replaced NumPad "123" with a mode toggle button that sends Shift+Tab to toggle Claude Code auto-accept/plan mode from mobile. Added dual-mode DPad: tap toggles between cursor mode (arrow keys) and scroll mode (tmux scroll via SGR mouse sequences). Visual indicator changes to accent color in scroll mode. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/terminalControls.test.tsx | 30 ++-- src/client/components/DPad.tsx | 146 ++++++++++++------ src/client/components/Terminal.tsx | 18 +++ src/client/components/TerminalControls.tsx | 42 ++++- src/server/SessionManager.ts | 21 +-- src/server/__tests__/sessionManager.test.ts | 3 +- start-agentboard.sh | 29 ++++ 7 files changed, 207 insertions(+), 82 deletions(-) create mode 100755 start-agentboard.sh diff --git a/src/client/__tests__/terminalControls.test.tsx b/src/client/__tests__/terminalControls.test.tsx index 1508888e..770127b1 100644 --- a/src/client/__tests__/terminalControls.test.tsx +++ b/src/client/__tests__/terminalControls.test.tsx @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' import TestRenderer, { act } from 'react-test-renderer' -import NumPad from '../components/NumPad' const globalAny = globalThis as typeof globalThis & { navigator?: Navigator @@ -17,14 +16,9 @@ afterEach(() => { function findPasteButton(renderer: TestRenderer.ReactTestRenderer) { const buttons = renderer.root.findAllByType('button') - return buttons.find((button) => { - const child = button.props.children - return ( - child?.type === 'svg' && - child.props?.stroke === 'currentColor' && - child.props?.fill === 'none' - ) - }) + return buttons.find((button) => + button.props['aria-label'] === 'Paste' + ) } describe('TerminalControls', () => { @@ -53,19 +47,27 @@ describe('TerminalControls', () => { ctrlButton.props.onClick() }) - const numpad = renderer.root.findByType(NumPad) + // Find the mode toggle button (sends Shift+Tab) and use it to test ctrl modifier + const modeButton = renderer.root.findAllByType('button').find( + (button) => button.props['aria-label'] === 'Toggle mode (Shift+Tab)' + ) + if (!modeButton) { + throw new Error('Expected mode toggle button') + } act(() => { - numpad.props.onSendKey('a') + modeButton.props.onClick() }) - expect(sent[0]).toBe(String.fromCharCode(1)) + // Ctrl + Shift+Tab sends the raw escape sequence (non-letter, ctrl consumed) + expect(sent[0]).toBe('\x1b[Z') + // After ctrl is consumed, next press should be normal act(() => { - numpad.props.onSendKey('a') + modeButton.props.onClick() }) - expect(sent[1]).toBe('a') + expect(sent[1]).toBe('\x1b[Z') }) test('session switcher selects sessions when multiple are present', () => { diff --git a/src/client/components/DPad.tsx b/src/client/components/DPad.tsx index 6c94aaa4..71d34b3a 100644 --- a/src/client/components/DPad.tsx +++ b/src/client/components/DPad.tsx @@ -1,14 +1,17 @@ /** - * DPad - Virtual joystick for mobile terminal navigation - * Long press to activate, drag in any direction to send arrow keys - * Uses joystick pattern so finger position doesn't obscure controls + * DPad - Virtual joystick for mobile terminal navigation and scrolling + * Tap to toggle between cursor mode (arrow keys) and scroll mode (tmux scroll) + * Long press to activate joystick, drag in any direction to send keys/scroll */ import { useState, useRef, useCallback, useEffect, type TouchEvent } from 'react' import { MoveIcon } from '@untitledui-icons/react/line' +export type DPadMode = 'cursor' | 'scroll' + interface DPadProps { onSendKey: (key: string) => void + onSendScroll?: (direction: 'up' | 'down') => void disabled?: boolean onRefocus?: () => void isKeyboardVisible?: () => boolean @@ -30,6 +33,7 @@ const REPEAT_INTERVAL_MIN = 400 // ms between keys at max distance (fast) const REPEAT_INTERVAL_MAX = 1500 // ms between keys at min distance (slow) const DEAD_ZONE = 15 // pixels from center before direction registers const JOYSTICK_RADIUS = 70 // visual radius of joystick +const TAP_MAX_DURATION = 200 // ms - taps shorter than this toggle mode function triggerHaptic(intensity: number = 10) { if ('vibrate' in navigator) { @@ -65,23 +69,42 @@ export function getRepeatInterval(distance: number): number { return REPEAT_INTERVAL_MAX - normalizedDistance * (REPEAT_INTERVAL_MAX - REPEAT_INTERVAL_MIN) } +// Scroll icon - vertical double arrows +const ScrollIcon = ( + + + + + +) + export default function DPad({ onSendKey, + onSendScroll, disabled = false, onRefocus, isKeyboardVisible, }: DPadProps) { const [isOpen, setIsOpen] = useState(false) + const [mode, setMode] = useState('cursor') const [activeDirection, setActiveDirection] = useState(null) const [joystickCenter, setJoystickCenter] = useState({ x: 0, y: 0 }) const [knobOffset, setKnobOffset] = useState({ x: 0, y: 0 }) const longPressTimerRef = useRef | null>(null) + const longPressFiredRef = useRef(false) + const touchStartTimeRef = useRef(0) const repeatTimerRef = useRef | null>(null) const repeatIntervalRef = useRef | null>(null) const wasKeyboardVisibleRef = useRef(false) const currentDirectionRef = useRef(null) const currentDistanceRef = useRef(0) + const modeRef = useRef('cursor') + + // Keep modeRef in sync + useEffect(() => { + modeRef.current = mode + }, [mode]) // Clean up all timers const clearAllTimers = useCallback(() => { @@ -111,35 +134,50 @@ export default function DPad({ } }, []) - // Schedule next repeat based on current distance - const scheduleNextRepeat = useCallback((key: string) => { + // Schedule next repeat based on current distance and mode + const scheduleNextRepeat = useCallback((direction: 'up' | 'down' | 'left' | 'right') => { const interval = getRepeatInterval(currentDistanceRef.current) repeatIntervalRef.current = setTimeout(() => { if (currentDirectionRef.current) { triggerHaptic(5) - onSendKey(key) - scheduleNextRepeat(key) + if (modeRef.current === 'scroll') { + if (direction === 'up' || direction === 'down') { + onSendScroll?.(direction) + } + } else { + onSendKey(ARROW_KEYS[direction]) + } + scheduleNextRepeat(direction) } }, interval) as unknown as ReturnType - }, [onSendKey]) + }, [onSendKey, onSendScroll]) // Start sending a direction with auto-repeat const startDirection = useCallback((direction: Direction, distance: number) => { if (!direction || disabled) return - const key = ARROW_KEYS[direction] + // In scroll mode, ignore left/right + if (modeRef.current === 'scroll' && (direction === 'left' || direction === 'right')) { + return + } + currentDistanceRef.current = distance triggerHaptic(8) - onSendKey(key) + + if (modeRef.current === 'scroll') { + onSendScroll?.(direction as 'up' | 'down') + } else { + onSendKey(ARROW_KEYS[direction]) + } // Clear any existing repeat timers stopKeyRepeat() // Start auto-repeat after initial delay repeatTimerRef.current = setTimeout(() => { - scheduleNextRepeat(key) + scheduleNextRepeat(direction) }, REPEAT_INITIAL_DELAY) - }, [disabled, onSendKey, stopKeyRepeat, scheduleNextRepeat]) + }, [disabled, onSendKey, onSendScroll, stopKeyRepeat, scheduleNextRepeat]) // Update direction based on finger position const updateDirection = useCallback((clientX: number, clientY: number) => { @@ -189,8 +227,11 @@ export default function DPad({ const touch = e.touches[0] wasKeyboardVisibleRef.current = isKeyboardVisible?.() ?? false + longPressFiredRef.current = false + touchStartTimeRef.current = performance.now() longPressTimerRef.current = setTimeout(() => { + longPressFiredRef.current = true triggerHaptic(15) // Position joystick centered above the touch point setJoystickCenter({ x: touch.clientX, y: touch.clientY - 80 }) @@ -222,6 +263,13 @@ export default function DPad({ if (isOpen) { closeJoystick() + } else if (!longPressFiredRef.current) { + // Short tap — toggle mode + const tapDuration = performance.now() - touchStartTimeRef.current + if (tapDuration < TAP_MAX_DURATION) { + triggerHaptic(10) + setMode(prev => prev === 'cursor' ? 'scroll' : 'cursor') + } } }, [isOpen, closeJoystick]) @@ -247,24 +295,28 @@ export default function DPad({ { dir: 'left' as const, angle: 180, label: '←' }, ] + const isScrollMode = mode === 'scroll' + return ( <> {/* Trigger button */} {/* Joystick overlay - renders in portal position */} @@ -306,30 +358,36 @@ export default function DPad({ }} > {/* Direction indicators */} - {directionArrows.map(({ dir, angle, label }) => ( -
- {label} -
- ))} + {directionArrows.map(({ dir, angle, label }) => { + const isHorizontal = dir === 'left' || dir === 'right' + const dimmed = isScrollMode && isHorizontal + return ( +
+ {label} +
+ ) + })} {/* Center knob */}
- {activeDirection.toUpperCase()} + {isScrollMode ? `SCROLL ${activeDirection.toUpperCase()}` : activeDirection.toUpperCase()}
)} diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 9d6de984..910b33c4 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -804,6 +804,23 @@ export default function Terminal({ [session, isReadOnly, sendMessage] ) + const handleSendScroll = useCallback( + (direction: 'up' | 'down') => { + if (!session || isReadOnly) return + const terminal = terminalRef.current + const cols = terminal?.cols ?? 80 + const rows = terminal?.rows ?? 24 + const col = Math.floor(cols / 2) + const row = Math.floor(rows / 2) + const button = direction === 'up' ? 64 : 65 + sendMessage({ type: 'terminal-input', sessionId: session.id, data: `\x1b[<${button};${col};${row}M` }) + if (direction === 'up') { + setTmuxCopyMode(true) + } + }, + [session, isReadOnly, sendMessage] + ) + const handleRefocus = useCallback(() => { const container = containerRef.current if (!container) return @@ -1090,6 +1107,7 @@ export default function Terminal({ {session && ( ({ id: s.id, name: s.name, status: s.status }))} currentSessionId={session.id} diff --git a/src/client/components/TerminalControls.tsx b/src/client/components/TerminalControls.tsx index bac27092..571aac67 100644 --- a/src/client/components/TerminalControls.tsx +++ b/src/client/components/TerminalControls.tsx @@ -9,7 +9,6 @@ import type { TouchEvent as ReactTouchEvent } from 'react' import type { Session } from '@shared/types' import { CornerDownLeftIcon } from '@untitledui-icons/react/line' import DPad from './DPad' -import NumPad from './NumPad' import { isIOSDevice } from '../utils/device' interface SessionInfo { @@ -20,6 +19,7 @@ interface SessionInfo { interface TerminalControlsProps { onSendKey: (key: string) => void + onSendScroll?: (direction: 'up' | 'down') => void disabled?: boolean sessions: SessionInfo[] currentSessionId: string | null @@ -113,8 +113,17 @@ const statusDot: Record = { unknown: 'bg-muted', } +// Mode toggle icon (Shift+Tab symbol) +const ModeToggleIcon = ( + + + + +) + export default function TerminalControls({ onSendKey, + onSendScroll, disabled = false, sessions, currentSessionId, @@ -469,17 +478,34 @@ export default function TerminalControls({ ))} - {/* NumPad for number input */} - e.preventDefault()} + onTouchStart={handleTouchAction(() => handlePress('\x1b[Z'))} + onClick={handleClickAction(() => handlePress('\x1b[Z'))} disabled={disabled} - onRefocus={onRefocus} - isKeyboardVisible={isKeyboardVisible} - /> + > + {ModeToggleIcon} + - {/* D-pad for arrow keys */} + {/* D-pad for arrow keys / scroll */} { ) expect(setOptionCall).toBeTruthy() expect(setOptionCall).toContain('off') - expect(setOptionCall).toContain('-t') - expect(setOptionCall).toContain(sessionName) + expect(setOptionCall).toContain('-g') }) test('setMouseMode applies change immediately', () => { diff --git a/start-agentboard.sh b/start-agentboard.sh new file mode 100755 index 00000000..e8b22ec1 --- /dev/null +++ b/start-agentboard.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Start agentboard inside a tmux session for proper TTY support. +# Used by the launchd agent for auto-start on login. + +export PATH="/Users/kenneth/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" +export TLS_CERT="/Users/kenneth/.agentboard/tls-cert.pem" +export TLS_KEY="/Users/kenneth/.agentboard/tls-key.pem" +export DISCOVER_PREFIXES="infra" + +# Prevent Claude Code nesting detection in tmux children +unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + +WORKING_DIR="/Users/kenneth/Desktop/lab/infra/agentboard" + +# Ensure the managed "agentboard" tmux session exists +if ! tmux has-session -t agentboard 2>/dev/null; then + tmux new-session -d -s agentboard +fi + +# Ensure the "infra" session exists with the agentboard-server window +if tmux has-session -t infra 2>/dev/null; then + if ! tmux list-windows -t infra -F '#{window_name}' | grep -q "^agentboard-server$"; then + tmux new-window -t infra -n agentboard-server -c "$WORKING_DIR" + tmux send-keys -t infra:agentboard-server "TLS_CERT=$TLS_CERT TLS_KEY=$TLS_KEY DISCOVER_PREFIXES=$DISCOVER_PREFIXES bun run start" Enter + fi +else + tmux new-session -d -s infra -n agentboard-server -c "$WORKING_DIR" + tmux send-keys -t infra:agentboard-server "TLS_CERT=$TLS_CERT TLS_KEY=$TLS_KEY DISCOVER_PREFIXES=$DISCOVER_PREFIXES bun run start" Enter +fi