feat: implement mobile terminal UX

- Add mobile viewport detection hook
- Add virtual keyboard detection with fallback
- Add auto-hide hook for header/keys strip
- Add special keys mapping hook
- Create MobileTerminalHeader, SpecialKeysStrip, SpecialKeysPanel components
- Create MobileTerminalWrapper component
- Update TerminalComponent with mobile support, font scaling, copy/paste, reconnection
- Update AppShell to hide chrome on mobile terminal pages
- Update TerminalPage to use MobileTerminalWrapper
- Add comprehensive mobile terminal styles
- TypeScript check passes
- Build succeeds
This commit is contained in:
Fusion
2026-05-24 11:30:04 +02:00
parent 312a646b89
commit b6bda3d692
19 changed files with 1721 additions and 81 deletions
+39 -3
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { useCallback, useEffect, useState } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
@@ -39,6 +40,11 @@ export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => {
try {
@@ -58,6 +64,19 @@ export const AppShell = () => {
return () => clearInterval(interval);
}, [loadSessions]);
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false);
}, [location.pathname]);
if (isMobileTerminal) {
return (
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
);
}
return (
<div className="shell">
<header className="shell-header">
@@ -82,7 +101,17 @@ export const AppShell = () => {
</header>
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
{isMobile && (
<button
className="mobile-menu-close"
onClick={() => setMobileMenuOpen(false)}
type="button"
aria-label="Close menu"
>
<Icon name="close" size="sm" />
</button>
)}
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
@@ -112,6 +141,13 @@ export const AppShell = () => {
)}
</aside>
{isMobile && mobileMenuOpen && (
<div
className="mobile-menu-overlay"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<main className="shell-content">
<Outlet />
</main>
@@ -0,0 +1,72 @@
import React from "react";
import { Icon } from "./icon";
interface MobileTerminalHeaderProps {
instanceName?: string;
onBack?: () => void;
onMenuToggle?: () => void;
onClose?: () => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
instanceName,
onBack,
onMenuToggle,
onClose,
isVisible,
connectionStatus = "connecting",
}) => {
return (
<div
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
>
<div className="mobile-terminal-header-left">
{onBack && (
<button
className="mobile-terminal-header-button"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="sm" />
</button>
)}
{onMenuToggle && (
<button
className="mobile-terminal-header-button"
onClick={onMenuToggle}
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
<div className="mobile-terminal-header-center">
<span className="mobile-terminal-header-title">
{instanceName || "Terminal"}
</span>
<span
className={`mobile-terminal-header-status ${connectionStatus}`}
aria-label={`Connection status: ${connectionStatus}`}
/>
</div>
<div className="mobile-terminal-header-right">
{onClose && (
<button
className="mobile-terminal-header-button"
onClick={onClose}
type="button"
aria-label="Close terminal"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import React, { useState, useCallback } from "react";
import { TerminalComponent } from "./terminal";
import { MobileTerminalHeader } from "./mobile-terminal-header";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
interface MobileTerminalWrapperProps {
instanceId: string;
instanceName?: string;
onClose?: () => void;
onBack?: () => void;
onMenuToggle?: () => void;
}
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId,
instanceName,
onClose,
onBack,
onMenuToggle,
}) => {
const isMobile = useMobileViewport();
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
} | null>(null);
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const keysAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const handleTerminalTap = useCallback(() => {
headerAutoHide.toggle();
keysAutoHide.toggle();
}, [headerAutoHide, keysAutoHide]);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
);
if (!isMobile) {
return (
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={false}
/>
);
}
return (
<div className="mobile-terminal-wrapper">
<MobileTerminalHeader
instanceName={instanceName}
onBack={onBack}
onMenuToggle={onMenuToggle}
onClose={onClose}
isVisible={headerAutoHide.isVisible}
connectionStatus={terminalRef?.connectionStatus}
/>
<div
className="mobile-terminal-content"
style={{
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
}}
onClick={handleTerminalTap}
>
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={true}
onTerminalReady={(sendData, connectionStatus) =>
setTerminalRef({ sendData, connectionStatus })
}
/>
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={keysAutoHide.isVisible && !showPanel}
onMoreClick={() => setShowPanel(true)}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showPanel}
onClose={() => setShowPanel(false)}
/>
</div>
);
};
@@ -0,0 +1,84 @@
import React from "react";
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "home", label: "Home" },
{ key: "end", label: "End" },
{ key: "pageup", label: "PgUp" },
{ key: "pagedown", label: "PgDn" },
{ key: "ctrlc", label: "Ctrl+C" },
{ key: "ctrld", label: "Ctrl+D" },
{ key: "ctrlz", label: "Ctrl+Z" },
];
const F_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "f1", label: "F1" },
{ key: "f2", label: "F2" },
{ key: "f3", label: "F3" },
{ key: "f4", label: "F4" },
{ key: "f5", label: "F5" },
{ key: "f6", label: "F6" },
{ key: "f7", label: "F7" },
{ key: "f8", label: "F8" },
{ key: "f9", label: "F9" },
{ key: "f10", label: "F10" },
{ key: "f11", label: "F11" },
{ key: "f12", label: "F12" },
];
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
onSend,
isOpen,
onClose,
}) => {
const { sendKey } = useSpecialKeys({ onSend });
if (!isOpen) return null;
return (
<div className="special-keys-panel-overlay" onClick={onClose}>
<div
className="special-keys-panel"
onClick={(e) => e.stopPropagation()}
>
<div className="special-keys-panel-section">
{EXPANDED_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onClick={() => {
sendKey(key);
onClose();
}}
type="button"
>
{label}
</button>
))}
</div>
<div className="special-keys-panel-divider" />
<div className="special-keys-panel-section">
{F_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onClick={() => {
sendKey(key);
onClose();
}}
type="button"
>
{label}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,53 @@
import React from "react";
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "escape", label: "Esc" },
{ key: "tab", label: "Tab" },
{ key: "ctrl", label: "Ctrl" },
{ key: "alt", label: "Alt" },
{ key: "up", label: "↑" },
{ key: "down", label: "↓" },
{ key: "left", label: "←" },
{ key: "right", label: "→" },
];
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
onSend,
isVisible,
onMoreClick,
}) => {
const { sendKey } = useSpecialKeys({ onSend });
return (
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
{PRIMARY_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onClick={() => sendKey(key)}
type="button"
aria-label={`Send ${label}`}
>
{label}
</button>
))}
{onMoreClick && (
<button
className="special-key-button special-key-more"
onClick={onMoreClick}
type="button"
aria-label="More special keys"
>
More
</button>
)}
</div>
);
};
+290 -76
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
@@ -7,23 +7,117 @@ import "xterm/css/xterm.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
isMobile?: boolean;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error"
) => void;
}
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 16;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile = false,
onTerminalReady,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const reconnectAttemptsRef = useRef(0);
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error"
>("connecting");
const [error, setError] = useState<string | null>(null);
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY);
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
});
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
const vw = window.innerWidth;
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
return Math.round(calculated);
}, [isMobile, fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
termRef.current?.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (document.visibilityState !== "hidden") {
connectWebSocket();
}
}, delay);
}
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId]);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontSize: currentFontSize,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
@@ -49,57 +143,18 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
},
});
termRef.current = term;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
const ws = connectWebSocket();
// Handle terminal input
term.onData((data) => {
@@ -108,19 +163,23 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
}
});
// Handle resize
// Handle resize with debounce
let resizeTimeout: ReturnType<typeof setTimeout>;
const handleResize = () => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
}, 250);
};
window.addEventListener("resize", handleResize);
@@ -128,31 +187,186 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
// Initial resize
setTimeout(handleResize, 100);
// Notify parent about terminal readiness
if (onTerminalReady) {
const sendData = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
};
onTerminalReady(sendData, status);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
clearTimeout(resizeTimeout);
window.removeEventListener("resize", handleResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
ws.close();
term.dispose();
};
}, [instanceId]);
}, [instanceId, connectWebSocket, onTerminalReady, status, calculateFontSize]);
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
onTerminalReady(sendData, status);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current) {
termRef.current.options.fontSize = newSize;
fitAddonRef.current?.fit();
}
};
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus hidden input on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && hiddenInputRef.current) {
hiddenInputRef.current.focus();
}
};
return (
<div className="terminal-wrapper">
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
<div className="terminal-header">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
<div className="terminal-header-right">
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
</>
)}
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
{error && (
<div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
</div>
);
};