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>
);
};
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from "react";
interface AutoHideOptions {
timeout?: number;
enabled?: boolean;
}
export function useAutoHide(options: AutoHideOptions = {}) {
const { timeout = 3000, enabled = true } = options;
const [isVisible, setIsVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastInteractionRef = useRef(Date.now());
const show = useCallback(() => {
if (!enabled) return;
setIsVisible(true);
lastInteractionRef.current = Date.now();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, timeout);
}, [enabled, timeout]);
const hide = useCallback(() => {
if (!enabled) return;
setIsVisible(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [enabled]);
const toggle = useCallback(() => {
if (!enabled) return;
if (isVisible) {
hide();
} else {
show();
}
}, [enabled, isVisible, show, hide]);
useEffect(() => {
if (!enabled) {
setIsVisible(true);
return;
}
// Start the timer initially
show();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [enabled, show]);
return {
isVisible,
show,
hide,
toggle,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from "react";
const MOBILE_BREAKPOINT = 768;
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.innerWidth < MOBILE_BREAKPOINT;
});
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return isMobile;
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback } from "react";
export type SpecialKey =
| "escape"
| "tab"
| "ctrl"
| "alt"
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "pageup"
| "pagedown"
| "ctrlc"
| "ctrld"
| "ctrlz"
| "f1"
| "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12";
const KEY_SEQUENCES: Record<SpecialKey, string> = {
escape: "\x1B",
tab: "\t",
ctrl: "",
alt: "",
up: "\x1B[A",
down: "\x1B[B",
right: "\x1B[C",
left: "\x1B[D",
home: "\x1B[H",
end: "\x1B[F",
pageup: "\x1B[5~",
pagedown: "\x1B[6~",
ctrlc: "\x03",
ctrld: "\x04",
ctrlz: "\x1A",
f1: "\x1BOP",
f2: "\x1BOQ",
f3: "\x1BOR",
f4: "\x1BOS",
f5: "\x1B[15~",
f6: "\x1B[17~",
f7: "\x1B[18~",
f8: "\x1B[19~",
f9: "\x1B[20~",
f10: "\x1B[21~",
f11: "\x1B[23~",
f12: "\x1B[24~",
};
interface UseSpecialKeysOptions {
onSend: (data: string) => void;
}
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) {
const sendKey = useCallback(
(key: SpecialKey) => {
const sequence = KEY_SEQUENCES[key];
if (sequence) {
onSend(sequence);
}
},
[onSend]
);
return { sendKey };
}
export { KEY_SEQUENCES };
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback } from "react";
interface VirtualKeyboardState {
isOpen: boolean;
height: number;
viewportHeight: number;
}
export function useVirtualKeyboard() {
const [state, setState] = useState<VirtualKeyboardState>({
isOpen: false,
height: 0,
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
});
const updateKeyboardState = useCallback(() => {
const visualViewport = window.visualViewport;
const windowHeight = window.innerHeight;
if (visualViewport) {
const viewportHeight = visualViewport.height;
const keyboardHeight = windowHeight - viewportHeight;
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
setState({
isOpen,
height: keyboardHeight,
viewportHeight,
});
} else {
// Fallback: compare window height to a stored reference
// This is less reliable but works on older browsers
const currentHeight = windowHeight;
const isOpen = currentHeight < state.viewportHeight - 100;
setState((prev) => ({
isOpen,
height: isOpen ? prev.viewportHeight - currentHeight : 0,
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
}));
}
}, [state.viewportHeight]);
useEffect(() => {
const visualViewport = window.visualViewport;
if (visualViewport) {
visualViewport.addEventListener("resize", updateKeyboardState);
visualViewport.addEventListener("scroll", updateKeyboardState);
} else {
window.addEventListener("resize", updateKeyboardState);
}
// Initial check
updateKeyboardState();
return () => {
if (visualViewport) {
visualViewport.removeEventListener("resize", updateKeyboardState);
visualViewport.removeEventListener("scroll", updateKeyboardState);
} else {
window.removeEventListener("resize", updateKeyboardState);
}
};
}, [updateKeyboardState]);
return state;
}
+14 -2
View File
@@ -1,11 +1,13 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon";
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!instanceId) {
return (
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
);
}
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
return (
<section className="terminal-page">
<div className="terminal-page-header">
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
onClick={() => navigate(-1)}
type="button"
>
<Icon name="arrow-left" size="sm" />
Back
</button>
<h1>Terminal</h1>
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
isMobile={false}
/>
</section>
);
+368
View File
@@ -2873,3 +2873,371 @@ a.nav-item,
background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626);
}
/* ============================================
Mobile Terminal Styles
============================================ */
.mobile-terminal-shell {
height: 100vh;
overflow: hidden;
}
.mobile-terminal-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
background: #1e1e1e;
position: relative;
overflow: hidden;
}
/* Mobile Terminal Header */
.mobile-terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 100;
}
.mobile-terminal-header.hidden {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
}
.mobile-terminal-header.visible {
transform: translateY(0);
opacity: 1;
}
.mobile-terminal-header-left,
.mobile-terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 0 0 auto;
}
.mobile-terminal-header-center {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
justify-content: center;
min-width: 0;
}
.mobile-terminal-header-title {
font-size: 0.875rem;
font-weight: 500;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-terminal-header-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: 1px solid #3e3e3e;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s ease;
}
.mobile-terminal-header-button:hover {
background: #3e3e3e;
}
.mobile-terminal-header-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.mobile-terminal-header-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.mobile-terminal-header-status.connected {
background: #0dbc79;
}
.mobile-terminal-header-status.disconnected,
.mobile-terminal-header-status.error {
background: #cd3131;
}
/* Mobile Terminal Content */
.mobile-terminal-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
/* Special Keys Strip */
.special-keys-strip {
display: flex;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex-shrink: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 100;
}
.special-keys-strip.hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.special-keys-strip.visible {
transform: translateY(0);
opacity: 1;
}
.special-keys-strip::-webkit-scrollbar {
display: none;
}
.special-key-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 0 var(--space-2);
background: #3e3e3e;
border: 1px solid #4e4e4e;
border-radius: 6px;
color: #d4d4d4;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.15s ease, transform 0.1s ease;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.special-key-button:active {
background: #4e4e4e;
transform: scale(0.95);
}
.special-key-more {
background: #2472c8;
border-color: #2472c8;
color: white;
}
.special-key-more:active {
background: #1e5fa8;
}
/* Special Keys Panel */
.special-keys-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.special-keys-panel {
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
border-radius: 12px 12px 0 0;
padding: var(--space-4);
width: 100%;
max-height: 70vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
.special-keys-panel-section {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.special-keys-panel-section:last-child {
margin-bottom: 0;
}
.special-keys-panel-divider {
height: 1px;
background: #3e3e3e;
margin: var(--space-3) 0;
}
/* Terminal Component Updates */
.terminal-wrapper.mobile {
border: none;
border-radius: 0;
height: 100%;
}
.terminal-wrapper.mobile .terminal-header {
display: none;
}
.terminal-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-button {
padding: var(--space-1) var(--space-2);
background: transparent;
border: 1px solid #666;
border-radius: 4px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.2s ease;
}
.terminal-header-button:hover {
background: #3e3e3e;
}
.terminal-reconnect {
margin-left: var(--space-2);
padding: var(--space-1) var(--space-2);
background: #2472c8;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.75rem;
}
.terminal-hidden-input {
position: absolute;
bottom: 0;
left: 0;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
/* Disable zoom on mobile terminal */
@media (max-width: 767px) {
.mobile-terminal-wrapper {
touch-action: none;
-webkit-text-size-adjust: none;
}
.mobile-terminal-wrapper * {
touch-action: manipulation;
}
.terminal-container {
touch-action: none;
}
}
/* Animations */
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile Menu Overlay */
.mobile-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
.mobile-menu-close {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--text);
cursor: pointer;
}
/* AppShell mobile menu */
@media (max-width: 767px) {
.shell-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
background: var(--bg);
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
padding-top: var(--space-8);
}
.shell-nav.mobile-open {
transform: translateX(0);
}
}