feat(frontend): terminal pilotable au doigt sur téléphone (#69)

Lots 3 et 4 du ticket #69. Sans ça un agent CLI est indriveable depuis un
téléphone : le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches, donc
on peut taper un prompt mais pas l'interrompre, compléter un chemin, sortir
d'un éditeur ou rappeler l'historique.

- `TerminalView` expose un `onReady(api)` optionnel (donc desktop inchangé,
  et inerte quand xterm ne monte pas). `api.send` passe par `term.input()` :
  le *même* chemin qu'une frappe réelle, donc le relais PTY, le comptage de
  lignes et la suspension du write-portal s'appliquent à l'identique. Écrire
  sur le handle aurait court-circuité le portal.
- `TerminalKeyBar` (web-only) : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips
  tactiles 44px, scroll horizontal. Chaque tap annule le déplacement de
  focus et refocalise xterm — sinon le clavier virtuel se referme à chaque
  touche.
- La cellule agent passe de `h-64` fixe (une letterbox d'~20 lignes) à
  `h-[60dvh]` sur téléphone, `sm:h-80` au-delà.
- Tests : séquences d'octets émises, préservation du focus, état désactivé
  avant montage de xterm, et garde de non-régression sur la frontière —
  le client web ne monte aucun layout-grid/split/dock desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:28:09 +02:00
parent 48b8853214
commit 62ecefe1e8
7 changed files with 354 additions and 10 deletions

View File

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

View File

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

View File

@ -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(<TerminalKeyBar api={api} />);
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(<TerminalKeyBar api={api} />);
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(<TerminalKeyBar api={api} />);
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(<TerminalKeyBar api={null} />);
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(<TerminalKeyBar api={api} />);
expect(screen.getByRole("toolbar", { name: "Touches du terminal" })).toBeTruthy();
});
});

View File

@ -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 (
<div
role="toolbar"
aria-label="Touches du terminal"
data-testid="terminal-key-bar"
// Scrolls horizontally rather than wrapping: the chips stay on one line at
// any width, so the bar's height never changes and can't shove the
// terminal around while the keyboard is open.
className={cn(
"flex shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-raised/40 px-2 py-1.5",
className,
)}
>
{KEYS.map((key) => (
<Button
key={key.label}
size="sm"
variant="secondary"
aria-label={key.aria ?? key.label}
disabled={!api}
// Keep the virtual keyboard up: cancel the focus shift the tap would
// otherwise cause, then hand focus back to the terminal explicitly.
onPointerDown={(e) => e.preventDefault()}
onClick={() => {
api?.send(key.data);
api?.focus();
}}
// 44px touch target (min-h-11) — below that these are hard to hit.
className="min-h-11 shrink-0 font-mono"
>
{key.label}
</Button>
))}
</div>
);
}

View File

@ -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<string | null>(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<TerminalInputApi | null>(null);
const open = useCallback(
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
@ -62,15 +67,27 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr
);
return (
<div data-testid="web-agent-cell" className="h-64 w-full">
<TerminalView
cwd={cwd}
agentMode
open={open}
reattach={reattach}
sessionId={sessionId}
onSessionId={setSessionId}
/>
// #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.
<div
data-testid="web-agent-cell"
className="flex h-[60dvh] min-h-64 w-full flex-col overflow-hidden rounded-md border border-border sm:h-80"
>
<div className="min-h-0 flex-1">
<TerminalView
cwd={cwd}
agentMode
open={open}
reattach={reattach}
sessionId={sessionId}
onSessionId={setSessionId}
onReady={setInputApi}
/>
</div>
<TerminalKeyBar api={inputApi} />
</div>
);
}

View File

@ -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<string, string>();
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<Gateways> {
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(
<DIProvider gateways={await seededGateways()}>
<WebApp session={session} />
</DIProvider>,
);
}
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");
});
});

View File

@ -7,3 +7,4 @@ export { WebApp } from "./WebApp";
export { PairingScreen } from "./PairingScreen";
export { WebWorkspace } from "./WebWorkspace";
export { WebAgentCell } from "./WebAgentCell";
export { TerminalKeyBar } from "./TerminalKeyBar";