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:
@ -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);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
76
frontend/src/features/web/TerminalKeyBar.test.tsx
Normal file
76
frontend/src/features/web/TerminalKeyBar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
90
frontend/src/features/web/TerminalKeyBar.tsx
Normal file
90
frontend/src/features/web/TerminalKeyBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,7 +67,16 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="web-agent-cell" className="h-64 w-full">
|
||||
// #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
|
||||
@ -70,7 +84,10 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr
|
||||
reattach={reattach}
|
||||
sessionId={sessionId}
|
||||
onSessionId={setSessionId}
|
||||
onReady={setInputApi}
|
||||
/>
|
||||
</div>
|
||||
<TerminalKeyBar api={inputApi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
117
frontend/src/features/web/WebMobile.test.tsx
Normal file
117
frontend/src/features/web/WebMobile.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -7,3 +7,4 @@ export { WebApp } from "./WebApp";
|
||||
export { PairingScreen } from "./PairingScreen";
|
||||
export { WebWorkspace } from "./WebWorkspace";
|
||||
export { WebAgentCell } from "./WebAgentCell";
|
||||
export { TerminalKeyBar } from "./TerminalKeyBar";
|
||||
|
||||
Reference in New Issue
Block a user