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:
gsxdsm
2026-06-05 02:27:46 -07:00
parent e10db81393
commit 7a80d29d4c
5 changed files with 817 additions and 2 deletions

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -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"
}
}