From 7a80d29d4cca137a27b4027c57f04e802af2f358 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 02:27:46 -0700 Subject: [PATCH] feat(dashboard): mobile terminal input bar with sticky Ctrl accessory keys (U13) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/cli-agent-mobile-terminal-input.md | 22 ++ .../app/components/SessionTerminal.css | 111 ++++++ .../app/components/SessionTerminal.tsx | 303 +++++++++++++- .../__tests__/SessionTerminal.mobile.test.tsx | 371 ++++++++++++++++++ packages/i18n/locales/en/app.json | 12 +- 5 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-agent-mobile-terminal-input.md create mode 100644 packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx diff --git a/.changeset/cli-agent-mobile-terminal-input.md b/.changeset/cli-agent-mobile-terminal-input.md new file mode 100644 index 0000000000..5ec4dff324 --- /dev/null +++ b/.changeset/cli-agent-mobile-terminal-input.md @@ -0,0 +1,22 @@ +--- +"@runfusion/fusion": minor +--- + +Mobile terminal interaction for cli-agent sessions (U13). `SessionTerminal` now +detects mobile viewports via the canonical breakpoint +(`(max-width: 768px), (max-height: 480px)`) and renders a bottom input model in +place of relying on xterm's hidden-textarea (unreliable on mobile): a visible +text input that forwards typed text + `\r` as input frames on submit, plus an +accessory key bar emitting exact control sequences — Esc (`0x1B`), Tab (`0x09`), +a dedicated Ctrl-C (`0x03`), ANSI CSI cursor arrows (`CSI A/B/C/D`), and a sticky +Ctrl modifier whose next key combines into a control byte (Ctrl-C `0x03`, +Ctrl-D `0x04`, Ctrl-Z `0x1A`) with a visible active state. + +Bar keys apply the iOS composer survival pattern (pointerdown/mousedown +preventDefault, action on click) so the input keeps focus, and the bar behaves as +a fixed footer that lifts above the virtual keyboard via `useMobileKeyboard` +(including its pinch-zoom `vv.scale > 1` guard, which is not treated as +keyboard-open). xterm `onData` input stays attached (the bar is primary, not +exclusive). Bar keys and the input are deliberate user keystrokes routed straight +to the session input path. All new strings are localized in the `app` i18n +catalog. diff --git a/packages/dashboard/app/components/SessionTerminal.css b/packages/dashboard/app/components/SessionTerminal.css index 5750880879..bef95a0fe5 100644 --- a/packages/dashboard/app/components/SessionTerminal.css +++ b/packages/dashboard/app/components/SessionTerminal.css @@ -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; + } +} diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index f3b9090248..cb60b4ec8d 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -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(() => { + 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(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 ( -
+
{posture && (
@@ -424,6 +577,154 @@ export function SessionTerminal({
)} + + {isMobile && !readOnly && ( +
+
+ + + + + + + + +
+
+ handleMobileInputChange(e.target.value)} + /> + +
+
+ )}
); } diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx new file mode 100644 index 0000000000..2af95f565d --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -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 = {}) { + const utils = render(); + 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 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(); + 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(); + 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(); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index dd787d1dda..a69015da4a 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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" } }