diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 20dd4cc..fede0f4 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -95,6 +95,32 @@ interface TerminalViewProps { * cells. */ portal?: WritePortal; + /** + * Called once, when xterm has mounted, with an imperative + * {@link TerminalInputApi} for this cell (#69). Optional and inert when + * absent, so the desktop path is unchanged; the web client uses it to drive + * the mobile key toolbar. Not called when xterm fails to mount (headless). + */ + onReady?: (api: TerminalInputApi) => void; +} + +/** + * Imperative handle over a mounted terminal (#69), handed to the caller by + * {@link TerminalViewProps.onReady}. + * + * It exists because a phone's virtual keyboard has no Esc, Tab, Ctrl or arrow + * keys, so the web client renders a key toolbar that needs to inject them. Both + * methods deliberately go through xterm rather than the PTY handle: + * `send` routes through `term.input()` — the *same* path a real keystroke takes + * — so the PTY relay, the write-portal's line counting and its suspension gate + * all keep applying. Writing to the handle directly would bypass the portal and + * let an injected key race a delegation. + */ +export interface TerminalInputApi { + /** Inject `data` exactly as if the user had typed it. */ + send: (data: string) => void; + /** Focus the terminal — on a phone this summons the virtual keyboard. */ + focus: () => void; } export function TerminalView({ @@ -105,6 +131,7 @@ export function TerminalView({ onSessionId, agentMode = false, portal, + onReady, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -136,6 +163,8 @@ export function TerminalView({ agentModeRef.current = agentMode; const portalRef = useRef(portal); portalRef.current = portal; + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; useEffect(() => { const container = containerRef.current; @@ -194,6 +223,19 @@ export function TerminalView({ else pending += data; }); + // Publish the input API now that the keystroke relay above is live, so an + // injected key is handled exactly like a typed one. Guarded on `disposed`: + // the caller may still hold this object after the cell unmounts, and driving + // a disposed xterm throws. + onReadyRef.current?.({ + send: (data) => { + if (!disposed) term.input(data); + }, + focus: () => { + if (!disposed) term.focus(); + }, + }); + const onData = (bytes: Uint8Array) => { if (!disposed) term.write(bytes); }; diff --git a/frontend/src/features/terminals/index.ts b/frontend/src/features/terminals/index.ts index ddb8486..1dba938 100644 --- a/frontend/src/features/terminals/index.ts +++ b/frontend/src/features/terminals/index.ts @@ -1,6 +1,7 @@ /** Terminals (L3): xterm.js wrapper bound to the `TerminalGateway`. */ export { TerminalView } from "./TerminalView"; +export type { TerminalInputApi } from "./TerminalView"; export { ResumeConversationPopup } from "./ResumeConversationPopup"; export { useWritePortal } from "./useWritePortal"; export type { UseWritePortalResult } from "./useWritePortal"; diff --git a/frontend/src/features/web/TerminalKeyBar.test.tsx b/frontend/src/features/web/TerminalKeyBar.test.tsx new file mode 100644 index 0000000..1703562 --- /dev/null +++ b/frontend/src/features/web/TerminalKeyBar.test.tsx @@ -0,0 +1,76 @@ +/** + * #69 lot 3 — the mobile key toolbar. + * + * Guards the two properties that make the terminal drivable from a phone: + * the chips emit the exact byte sequences a physical key would, and tapping one + * never steals focus from xterm (which would collapse the virtual keyboard). + */ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import type { TerminalInputApi } from "@/features/terminals"; +import { TerminalKeyBar } from "./TerminalKeyBar"; + +function mockApi(): TerminalInputApi & { sent: string[]; focused: () => number } { + const sent: string[] = []; + let focusCount = 0; + return { + sent, + focused: () => focusCount, + send: (d) => void sent.push(d), + focus: () => void focusCount++, + }; +} + +describe("TerminalKeyBar", () => { + it("sends the byte sequence a physical key would emit", () => { + const api = mockApi(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Ctrl-C (interrompre)" })); + fireEvent.click(screen.getByRole("button", { name: "Esc" })); + fireEvent.click(screen.getByRole("button", { name: "Tab" })); + fireEvent.click(screen.getByRole("button", { name: "Flèche haut" })); + + expect(api.sent).toEqual(["\x03", "\x1b", "\t", "\x1b[A"]); + }); + + it("emits the CSI sequences for every arrow key", () => { + const api = mockApi(); + render(); + + for (const name of ["Flèche haut", "Flèche bas", "Flèche gauche", "Flèche droite"]) { + fireEvent.click(screen.getByRole("button", { name })); + } + + expect(api.sent).toEqual(["\x1b[A", "\x1b[B", "\x1b[D", "\x1b[C"]); + }); + + it("keeps the virtual keyboard up: cancels the tap's focus shift, refocuses xterm", () => { + const api = mockApi(); + render(); + const ctrlC = screen.getByRole("button", { name: "Ctrl-C (interrompre)" }); + + // `fireEvent` returns false when the handler called preventDefault — that is + // what stops the browser blurring xterm's textarea and closing the keyboard. + expect(fireEvent.pointerDown(ctrlC)).toBe(false); + + fireEvent.click(ctrlC); + expect(api.focused()).toBe(1); + }); + + it("renders disabled until the terminal reports its input API", () => { + render(); + + for (const button of screen.getAllByRole("button")) { + expect((button as HTMLButtonElement).disabled).toBe(true); + } + }); + + it("exposes the keys as an accessible toolbar", () => { + const api = mockApi(); + render(); + + expect(screen.getByRole("toolbar", { name: "Touches du terminal" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/web/TerminalKeyBar.tsx b/frontend/src/features/web/TerminalKeyBar.tsx new file mode 100644 index 0000000..2b63ce6 --- /dev/null +++ b/frontend/src/features/web/TerminalKeyBar.tsx @@ -0,0 +1,90 @@ +/** + * Mobile key toolbar for the web terminal — ticket #69, lot 3. + * + * A phone's virtual keyboard has no Esc, Tab, Ctrl or arrow keys, so on a + * touch device a CLI agent is effectively undrivable: you can type a prompt but + * you cannot interrupt it (Ctrl-C), complete a path (Tab), leave an editor + * (Esc) or recall history (↑). This bar renders those keys as tappable chips. + * + * Two details carry the whole design: + * + * - **Focus must not move.** Tapping a chip would normally blur xterm's hidden + * textarea, which collapses the virtual keyboard — so every tap would cost + * the user their keyboard. Each chip therefore cancels the default + * pointer-down focus shift and re-focuses the terminal, keeping the keyboard + * up across taps. + * - **Keys are injected, not written.** `send` goes through + * {@link TerminalInputApi.send} (xterm's `input()`), the same path a typed + * key takes, so the PTY relay and the agent write-portal behave identically + * to real typing. + * + * It is web-only presentation: it holds no transport, and is rendered by + * {@link WebAgentCell} below the terminal. + */ + +import type { TerminalInputApi } from "@/features/terminals"; +import { Button, cn } from "@/shared"; + +interface TerminalKeyBarProps { + /** Input API of the mounted terminal; `null` until xterm reports ready. */ + api: TerminalInputApi | null; + className?: string; +} + +/** A key chip: what the user reads, and the bytes a real keyboard would emit. */ +interface KeyChip { + label: string; + /** Accessible name, when the glyph alone is not speakable (arrows). */ + aria?: string; + /** The exact sequence a physical key sends (xterm consumes it verbatim). */ + data: string; +} + +const KEYS: readonly KeyChip[] = [ + { label: "Esc", data: "\x1b" }, + { label: "Tab", data: "\t" }, + { label: "Ctrl-C", aria: "Ctrl-C (interrompre)", data: "\x03" }, + { label: "Ctrl-D", aria: "Ctrl-D (fin de saisie)", data: "\x04" }, + { label: "↑", aria: "Flèche haut", data: "\x1b[A" }, + { label: "↓", aria: "Flèche bas", data: "\x1b[B" }, + { label: "←", aria: "Flèche gauche", data: "\x1b[D" }, + { label: "→", aria: "Flèche droite", data: "\x1b[C" }, +]; + +export function TerminalKeyBar({ api, className }: TerminalKeyBarProps) { + return ( +
+ {KEYS.map((key) => ( + + ))} +
+ ); +} diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 992c628..45a4fb7 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -27,7 +27,8 @@ import type { TerminalHandle, } from "@/ports"; import { useGateways } from "@/app/di"; -import { TerminalView } from "@/features/terminals"; +import { TerminalView, type TerminalInputApi } from "@/features/terminals"; +import { TerminalKeyBar } from "./TerminalKeyBar"; interface WebAgentCellProps { /** Owning project id (resolved server-side by `agent.launch`). */ @@ -43,6 +44,10 @@ interface WebAgentCellProps { export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) { const { agent } = useGateways(); const [sessionId, setSessionId] = useState(null); + // Input API of the mounted xterm (#69), used by the mobile key bar. Stays + // `null` in headless renders where xterm cannot mount — the bar then renders + // disabled rather than throwing. + const [inputApi, setInputApi] = useState(null); const open = useCallback( (options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => @@ -62,15 +67,27 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr ); return ( -
- + // #69 — the cell was a flat `h-64` (256px), which on a phone left the agent + // a letterbox roughly 20 lines tall. It now takes 60% of the *dynamic* + // viewport on phones (so the collapsing URL bar can't clip it) and keeps a + // fixed, desktop-like height from `sm` up. `min-h-0` on the terminal row + // lets it shrink inside the flex column instead of pushing the key bar off. +
+
+ +
+
); } diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx new file mode 100644 index 0000000..40ed361 --- /dev/null +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -0,0 +1,117 @@ +/** + * #69 lot 4 — the web client on a phone viewport. + * + * Two guards: + * + * 1. **No desktop-only shell.** The mobile brief assumed the web client had to + * trade docks / floating windows / a layout grid for a vertical stack. It + * never had them: #13 shipped it as a single-column read-only surface. That + * is easy to regress by reaching for a desktop component, so these tests pin + * the boundary — the web client mounts no `layout-*` grid and no dock. + * 2. **The agent cell is phone-drivable** — it carries the key toolbar, since a + * virtual keyboard alone cannot send Ctrl-C/Esc/Tab/arrows. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock"; +import { WebSession } from "@/adapters/http"; +import type { FetchLike } from "@/adapters/http/httpInvoker"; +import type { FlagStore } from "@/adapters/http/webSession"; +import { WebApp } from "./WebApp"; + +/** Widths we care about: a small phone, and a large one. */ +const PHONE_WIDTH = 360; + +function memStore(): FlagStore { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +const okFetch: FetchLike = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +async function seededGateways(): Promise { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Demo", "/srv/demo"); + (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, { + agents: [ + { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] }, + ], + conversations: [], + }); + return gateways; +} + +async function renderPairedWebApp() { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + render( + + + , + ); +} + +beforeEach(() => { + // jsdom defaults to 1024px; pin a phone width so anything reading the viewport + // sees a phone. (Tailwind breakpoints are CSS-only and unresolved in jsdom — + // hence the structural assertions below rather than computed-style ones.) + window.innerWidth = PHONE_WIDTH; + window.dispatchEvent(new Event("resize")); +}); + +describe("web client on a phone viewport", () => { + it("mounts no desktop-only layout grid, split or dock", async () => { + await renderPairedWebApp(); + await screen.findByText("Projets"); + + for (const testId of ["layout-grid", "layout-grid-container", "layout-split", "layout-tabs"]) { + expect(screen.queryByTestId(testId)).toBeNull(); + } + }); + + it("stacks the workspace vertically in a single scroll container", async () => { + await renderPairedWebApp(); + + const workspace = await screen.findByTestId("web-workspace"); + expect(workspace.className).toContain("flex-col"); + expect(workspace.className).toContain("overflow-y-auto"); + }); + + it("gives the agent cell a key toolbar so a phone can send Ctrl-C/Esc/arrows", async () => { + await renderPairedWebApp(); + + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); + + expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); + expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); + }); + + it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => { + await renderPairedWebApp(); + + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); + + // `dvh` (not `vh`/`h-64`) is what survives a mobile URL bar collapsing. + // Match exact class tokens: `min-h-64` legitimately contains "h-64". + const cell = await screen.findByTestId("web-agent-cell"); + const classes = cell.className.split(/\s+/); + expect(classes).toContain("h-[60dvh]"); + expect(classes).not.toContain("h-64"); + }); +}); diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts index ad46ed4..f3d5594 100644 --- a/frontend/src/features/web/index.ts +++ b/frontend/src/features/web/index.ts @@ -7,3 +7,4 @@ export { WebApp } from "./WebApp"; export { PairingScreen } from "./PairingScreen"; export { WebWorkspace } from "./WebWorkspace"; export { WebAgentCell } from "./WebAgentCell"; +export { TerminalKeyBar } from "./TerminalKeyBar";