feat(dashboard): mobile terminal input bar with sticky Ctrl accessory keys (U13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,3 +163,114 @@
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ── Mobile input model (U13) ──────────────────────────────────────────────
|
||||
* Visible input field + accessory key bar. xterm's hidden-textarea input is
|
||||
* unreliable on mobile (KTD), so the bar is the primary input surface. The
|
||||
* bar is a fixed footer that lifts above the virtual keyboard when it opens
|
||||
* (driven by useMobileKeyboard's keyboardOverlap, applied inline).
|
||||
*/
|
||||
.cli-session-terminal__mobile-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm)
|
||||
calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-bar--keyboard-open {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* `bottom` is set inline to keyboardOverlap so the bar clears the keyboard. */
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.cli-session-terminal__key-row {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.cli-session-terminal__key-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cli-terminal-key {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
min-height: 36px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.cli-terminal-key:active {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.cli-terminal-key--ctrl.cli-terminal-key--active {
|
||||
color: var(--accent-text, var(--button-primary-text));
|
||||
background: var(--accent, var(--color-primary));
|
||||
border-color: var(--accent, var(--color-primary));
|
||||
}
|
||||
|
||||
.cli-terminal-key--ctrlc {
|
||||
color: var(--warning, var(--color-warning));
|
||||
border-color: var(--warning, var(--color-warning));
|
||||
}
|
||||
|
||||
.cli-session-terminal__input-row {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 16px; /* >=16px avoids iOS focus zoom */
|
||||
color: var(--text);
|
||||
background: var(--input-bg, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--color-primary));
|
||||
}
|
||||
|
||||
.cli-session-terminal__mobile-send {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--button-primary-text, var(--accent-text));
|
||||
background: var(--button-primary-bg, var(--accent));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* On mobile the terminal viewport is read-mostly; the bar drives input. */
|
||||
@media (max-width: 768px), (max-height: 480px) {
|
||||
.cli-session-terminal__viewport {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-rea
|
||||
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { api } from "../api";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
/**
|
||||
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
|
||||
@@ -26,6 +27,70 @@ import { api } from "../api";
|
||||
const ACK_THRESHOLD_BYTES = 32 * 1024;
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Canonical mobile breakpoint (matches the repo CSS convention). Landscape
|
||||
* phones exceed 768px wide, so the height clause covers them too.
|
||||
*/
|
||||
const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
||||
|
||||
/**
|
||||
* Control sequences emitted by the accessory key bar (U13). These are
|
||||
* deliberate user keystrokes routed straight to the session input path —
|
||||
* exempt from U2's injected-text neutralization (which governs composed /
|
||||
* injected strings, not real keystrokes).
|
||||
*/
|
||||
const SEQ_ESC = "\x1b"; // 0x1B
|
||||
const SEQ_TAB = "\x09"; // 0x09
|
||||
const SEQ_CTRL_C = "\x03"; // 0x03
|
||||
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
|
||||
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
|
||||
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
|
||||
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
|
||||
|
||||
/**
|
||||
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
|
||||
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
|
||||
* Returns null for keys that have no meaningful Ctrl combination.
|
||||
*/
|
||||
function ctrlCombo(key: string): string | null {
|
||||
if (key.length !== 1) return null;
|
||||
const upper = key.toUpperCase();
|
||||
const code = upper.charCodeAt(0);
|
||||
if (code >= 0x40 && code <= 0x5f) {
|
||||
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
|
||||
function useIsMobileViewport(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||
const onChange = () => setIsMobile(mql.matches);
|
||||
onChange();
|
||||
// Safari < 14 only has addListener/removeListener.
|
||||
if (typeof mql.addEventListener === "function") {
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}
|
||||
mql.addListener(onChange);
|
||||
return () => mql.removeListener(onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
/** The posture surfaced on the session record (denormalized at launch, U15). */
|
||||
export interface SessionTerminalPosture {
|
||||
/** Adapter display name (single Terminal icon for all adapters). */
|
||||
@@ -109,6 +174,86 @@ export function SessionTerminal({
|
||||
const [advanceDismissed, setAdvanceDismissed] = useState(false);
|
||||
const [advancePending, setAdvancePending] = useState(false);
|
||||
|
||||
// ── Mobile input model (U13) ───────────────────────────────────────────────
|
||||
const isMobile = useIsMobileViewport();
|
||||
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
|
||||
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [mobileInput, setMobileInput] = useState("");
|
||||
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
||||
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
||||
const [ctrlSticky, setCtrlSticky] = useState(false);
|
||||
|
||||
/** Write raw bytes to the session input path (mobile bar + submit). */
|
||||
const sendInput = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
||||
* Ctrl combination, send the combined control byte and clear the modifier;
|
||||
* otherwise send the literal sequence. Keeps the input focused (the caller's
|
||||
* pointerdown preventDefault stops the blur).
|
||||
*/
|
||||
const emitBarKey = useCallback(
|
||||
(seq: string) => {
|
||||
if (ctrlSticky) {
|
||||
const combined = ctrlCombo(seq);
|
||||
setCtrlSticky(false);
|
||||
if (combined) {
|
||||
sendInput(combined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendInput(seq);
|
||||
},
|
||||
[ctrlSticky, sendInput],
|
||||
);
|
||||
|
||||
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
|
||||
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleMobileSubmit = useCallback(
|
||||
(e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.();
|
||||
// User-typed text + Enter — deliberate input, no neutralization.
|
||||
if (mobileInput) sendInput(mobileInput);
|
||||
sendInput("\r");
|
||||
setMobileInput("");
|
||||
},
|
||||
[mobileInput, sendInput],
|
||||
);
|
||||
|
||||
/**
|
||||
* Input onChange. When sticky Ctrl is armed, the next typed character is
|
||||
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
|
||||
* landing in the field — this is how Ctrl-letter chords beyond the bar's
|
||||
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
|
||||
* normally for free-text + Enter submit.
|
||||
*/
|
||||
const handleMobileInputChange = useCallback(
|
||||
(next: string) => {
|
||||
if (ctrlSticky && next.length > mobileInput.length) {
|
||||
// The newly-typed character is the last one appended.
|
||||
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
|
||||
const combined = ctrlCombo(ch);
|
||||
setCtrlSticky(false);
|
||||
if (combined) {
|
||||
sendInput(combined);
|
||||
return; // swallow — do not echo the raw key into the field
|
||||
}
|
||||
}
|
||||
setMobileInput(next);
|
||||
},
|
||||
[ctrlSticky, mobileInput, sendInput],
|
||||
);
|
||||
|
||||
// Re-arm the strip whenever a fresh idle window is offered.
|
||||
useEffect(() => {
|
||||
if (showConfirmAdvance) setAdvanceDismissed(false);
|
||||
@@ -323,7 +468,15 @@ export function SessionTerminal({
|
||||
const flagSummary = posture?.elevatedFlags?.join(", ");
|
||||
|
||||
return (
|
||||
<div className="cli-session-terminal" data-mode={mode} data-read-only={readOnly}>
|
||||
<div
|
||||
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
|
||||
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
||||
}`}
|
||||
data-mode={mode}
|
||||
data-read-only={readOnly}
|
||||
data-mobile={isMobile}
|
||||
data-keyboard-open={isMobile && keyboardOpen}
|
||||
>
|
||||
<header className="cli-session-terminal__header">
|
||||
{posture && (
|
||||
<div className="cli-session-terminal__posture-wrap">
|
||||
@@ -424,6 +577,154 @@ export function SessionTerminal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && !readOnly && (
|
||||
<div
|
||||
className={`cli-session-terminal__mobile-bar${
|
||||
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
||||
}`}
|
||||
data-testid="cli-terminal-mobile-bar"
|
||||
style={
|
||||
// Lift the fixed footer above the virtual keyboard when it's open.
|
||||
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="cli-session-terminal__key-row"
|
||||
data-testid="cli-terminal-key-bar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`cli-terminal-key cli-terminal-key--ctrl${
|
||||
ctrlSticky ? " cli-terminal-key--active" : ""
|
||||
}`}
|
||||
data-testid="cli-key-ctrl"
|
||||
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
|
||||
aria-pressed={ctrlSticky}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => setCtrlSticky((v) => !v)}
|
||||
>
|
||||
Ctrl
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-esc"
|
||||
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ESC)}
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-tab"
|
||||
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_TAB)}
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key cli-terminal-key--ctrlc"
|
||||
data-testid="cli-key-ctrl-c"
|
||||
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => {
|
||||
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
|
||||
setCtrlSticky(false);
|
||||
sendInput(SEQ_CTRL_C);
|
||||
}}
|
||||
>
|
||||
^C
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-up"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_UP)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-down"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-left"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cli-terminal-key"
|
||||
data-testid="cli-key-arrow-right"
|
||||
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="cli-session-terminal__input-row"
|
||||
onSubmit={handleMobileSubmit}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="cli-session-terminal__mobile-input"
|
||||
data-testid="cli-terminal-mobile-input"
|
||||
value={mobileInput}
|
||||
placeholder={t(
|
||||
"cliTerminal.mobileInputPlaceholder",
|
||||
"Type to send to the session…",
|
||||
)}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => handleMobileInputChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="cli-session-terminal__mobile-send"
|
||||
data-testid="cli-terminal-mobile-send"
|
||||
aria-label={t("cliTerminal.mobileSend", "Send")}
|
||||
// iOS pattern: act on click, preventDefault on pointer/mouse down
|
||||
// so the input doesn't blur (which dismisses the keyboard).
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => handleMobileSubmit()}
|
||||
>
|
||||
{t("cliTerminal.mobileSend", "Send")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
};
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
const apiMock = vi.fn();
|
||||
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
|
||||
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
|
||||
|
||||
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
|
||||
class FakeWS {
|
||||
static instances: FakeWS[] = [];
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
sent: string[] = [];
|
||||
constructor(public url: string) {
|
||||
FakeWS.instances.push(this);
|
||||
}
|
||||
send(d: string) {
|
||||
this.sent.push(d);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// ── matchMedia mock: drive the mobile breakpoint convention ─────────────────
|
||||
let matchMediaMatches = true;
|
||||
function installMatchMedia(matches: boolean) {
|
||||
matchMediaMatches = matches;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: matchMediaMatches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
import { SessionTerminal } from "../SessionTerminal";
|
||||
|
||||
/** Pull the parsed input frames a WS has sent. */
|
||||
function inputFrames(ws: FakeWS): string[] {
|
||||
return ws.sent
|
||||
.map((raw) => JSON.parse(raw))
|
||||
.filter((m) => m.type === "input")
|
||||
.map((m) => m.data as string);
|
||||
}
|
||||
|
||||
/** Render and wait for the WS attach channel to open. */
|
||||
async function renderMobile(props: Record<string, unknown> = {}) {
|
||||
const utils = render(<SessionTerminal sessionId="s1" {...props} />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
return { ...utils, ws: FakeWS.instances[0] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.write.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true); // mobile by default
|
||||
_resetInitialViewportHeight();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("SessionTerminal (mobile)", () => {
|
||||
it("renders the mobile input bar + accessory key bar on mobile viewports", async () => {
|
||||
await renderMobile();
|
||||
expect(screen.getByTestId("cli-terminal-mobile-bar")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
|
||||
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
|
||||
installMatchMedia(false);
|
||||
await renderMobile();
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the mobile bar when read-only", async () => {
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
||||
await renderMobile({ readOnly: true });
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Accessory bar control sequences ───────────────────────────────────────
|
||||
it("Esc key emits 0x1b as an input frame", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-esc"));
|
||||
expect(inputFrames(ws)).toContain("\x1b");
|
||||
});
|
||||
|
||||
it("Tab key emits 0x09", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-tab"));
|
||||
expect(inputFrames(ws)).toContain("\x09");
|
||||
});
|
||||
|
||||
it("dedicated Ctrl-C shortcut emits 0x03", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-ctrl-c"));
|
||||
expect(inputFrames(ws)).toContain("\x03");
|
||||
});
|
||||
|
||||
it("arrow keys emit ANSI CSI cursor sequences", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-down"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-right"));
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-left"));
|
||||
const frames = inputFrames(ws);
|
||||
expect(frames).toContain("\x1b[A");
|
||||
expect(frames).toContain("\x1b[B");
|
||||
expect(frames).toContain("\x1b[C");
|
||||
expect(frames).toContain("\x1b[D");
|
||||
});
|
||||
|
||||
// ── Sticky Ctrl modifier ──────────────────────────────────────────────────
|
||||
it("sticky Ctrl shows an active visual state", async () => {
|
||||
await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(ctrl);
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(ctrl.className).toContain("cli-terminal-key--active");
|
||||
|
||||
// Tapping again toggles it back off.
|
||||
fireEvent.click(ctrl);
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("sticky Ctrl + c → 0x03, + d → 0x04, + z → 0x1a (combined, swallowed from field)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
|
||||
// Ctrl + c
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "c" } });
|
||||
expect(input.value).toBe(""); // combined, not echoed
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false"); // cleared
|
||||
|
||||
// Ctrl + d
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "d" } });
|
||||
|
||||
// Ctrl + z
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.change(input, { target: { value: "z" } });
|
||||
|
||||
const frames = inputFrames(ws);
|
||||
expect(frames).toContain("\x03"); // Ctrl-C
|
||||
expect(frames).toContain("\x04"); // Ctrl-D
|
||||
expect(frames).toContain("\x1a"); // Ctrl-Z
|
||||
});
|
||||
|
||||
it("without sticky Ctrl, typed letters land in the field (no combine)", async () => {
|
||||
await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "d" } });
|
||||
expect(input.value).toBe("d");
|
||||
});
|
||||
|
||||
it("sticky Ctrl + arrow does not combine (no ctrl combo) but clears modifier", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const ctrl = screen.getByTestId("cli-key-ctrl");
|
||||
fireEvent.click(ctrl);
|
||||
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
|
||||
// Arrow has no Ctrl combo → literal CSI sequence is sent, modifier clears.
|
||||
expect(inputFrames(ws)).toContain("\x1b[A");
|
||||
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Input field submit ────────────────────────────────────────────────────
|
||||
it("submitting the input field sends the text then \\r", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "ls -la" } });
|
||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||
const frames = inputFrames(ws);
|
||||
const idx = frames.indexOf("ls -la");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(frames[idx + 1]).toBe("\r");
|
||||
// Field is cleared after submit.
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("input is user keystrokes — text is forwarded verbatim (no neutralization)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
// An escape sequence typed by the user is sent verbatim (deliberate input).
|
||||
fireEvent.change(input, { target: { value: "echo \x1b[31m" } });
|
||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||
expect(inputFrames(ws)).toContain("echo \x1b[31m");
|
||||
});
|
||||
|
||||
// ── iOS composer pattern: bar keys do not blur the input ──────────────────
|
||||
it("bar key pointerdown preventDefault keeps the input focused", async () => {
|
||||
await renderMobile();
|
||||
const esc = screen.getByTestId("cli-key-esc");
|
||||
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
|
||||
esc.dispatchEvent(pdEvent);
|
||||
expect(pdEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("send button pointerdown preventDefault keeps the input focused", async () => {
|
||||
await renderMobile();
|
||||
const send = screen.getByTestId("cli-terminal-mobile-send");
|
||||
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
|
||||
send.dispatchEvent(pdEvent);
|
||||
expect(pdEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
// ── AE6 mobile leg: same live bytes reach term.write ──────────────────────
|
||||
it("mobile attach renders the same live session bytes (data → term.write)", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const b64 = Buffer.from("live-bytes", "utf8").toString("base64");
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "data", data: b64 }) });
|
||||
await waitFor(() =>
|
||||
expect(mockTerm.write).toHaveBeenCalledWith("live-bytes", expect.any(Function)),
|
||||
);
|
||||
});
|
||||
|
||||
it("xterm onData input is still attached on mobile (bar is primary, not exclusive)", async () => {
|
||||
await renderMobile();
|
||||
expect(mockTerm.onData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
|
||||
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
|
||||
function installVisualViewport({
|
||||
innerHeight,
|
||||
vvHeight,
|
||||
scale = 1,
|
||||
vvOffsetTop = 0,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
vvHeight: number;
|
||||
scale?: number;
|
||||
vvOffsetTop?: number;
|
||||
}) {
|
||||
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: innerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: vvOffsetTop,
|
||||
offsetLeft: 0,
|
||||
scale,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event]?.push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWS.instances = [];
|
||||
mockTerm.onData.mockReset();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
installMatchMedia(true);
|
||||
_resetInitialViewportHeight();
|
||||
savedVisualViewport = window.visualViewport;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keyboard-open applies the fixed-footer class so the bar is not occluded", async () => {
|
||||
installVisualViewport({ innerHeight: 800, vvHeight: 600 });
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
|
||||
await waitFor(() => {
|
||||
const bar = screen.getByTestId("cli-terminal-mobile-bar");
|
||||
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
|
||||
// Bar lifted above the keyboard by keyboardOverlap (800 - 600 = 200).
|
||||
expect(bar.style.bottom).toBe("200px");
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => {
|
||||
installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 });
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
|
||||
// Give the keyboard hook a beat to settle; it must stay closed.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
});
|
||||
|
||||
const bar = screen.getByTestId("cli-terminal-mobile-bar");
|
||||
expect(bar.className).not.toContain("cli-session-terminal__mobile-bar--keyboard-open");
|
||||
expect(bar.getAttribute("data-keyboard-open")).not.toBe("true");
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
@@ -6914,6 +6914,16 @@
|
||||
"adapterSettings": "Adapter settings",
|
||||
"advancePrompt": "This session looks idle — advance to review?",
|
||||
"advance": "Advance",
|
||||
"notYet": "Not yet"
|
||||
"notYet": "Not yet",
|
||||
"mobileInputPlaceholder": "Type to send to the session…",
|
||||
"mobileSend": "Send",
|
||||
"mobileKeyEsc": "Send Escape",
|
||||
"mobileKeyTab": "Send Tab",
|
||||
"mobileKeyCtrlC": "Send Ctrl-C",
|
||||
"mobileKeyCtrl": "Sticky Ctrl modifier",
|
||||
"mobileKeyArrowUp": "Cursor up",
|
||||
"mobileKeyArrowDown": "Cursor down",
|
||||
"mobileKeyArrowLeft": "Cursor left",
|
||||
"mobileKeyArrowRight": "Cursor right"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user