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.
|
* cells.
|
||||||
*/
|
*/
|
||||||
portal?: WritePortal;
|
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({
|
export function TerminalView({
|
||||||
@ -105,6 +131,7 @@ export function TerminalView({
|
|||||||
onSessionId,
|
onSessionId,
|
||||||
agentMode = false,
|
agentMode = false,
|
||||||
portal,
|
portal,
|
||||||
|
onReady,
|
||||||
}: TerminalViewProps) {
|
}: TerminalViewProps) {
|
||||||
const { terminal } = useGateways();
|
const { terminal } = useGateways();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -136,6 +163,8 @@ export function TerminalView({
|
|||||||
agentModeRef.current = agentMode;
|
agentModeRef.current = agentMode;
|
||||||
const portalRef = useRef(portal);
|
const portalRef = useRef(portal);
|
||||||
portalRef.current = portal;
|
portalRef.current = portal;
|
||||||
|
const onReadyRef = useRef(onReady);
|
||||||
|
onReadyRef.current = onReady;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@ -194,6 +223,19 @@ export function TerminalView({
|
|||||||
else pending += data;
|
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) => {
|
const onData = (bytes: Uint8Array) => {
|
||||||
if (!disposed) term.write(bytes);
|
if (!disposed) term.write(bytes);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/** Terminals (L3): xterm.js wrapper bound to the `TerminalGateway`. */
|
/** Terminals (L3): xterm.js wrapper bound to the `TerminalGateway`. */
|
||||||
|
|
||||||
export { TerminalView } from "./TerminalView";
|
export { TerminalView } from "./TerminalView";
|
||||||
|
export type { TerminalInputApi } from "./TerminalView";
|
||||||
export { ResumeConversationPopup } from "./ResumeConversationPopup";
|
export { ResumeConversationPopup } from "./ResumeConversationPopup";
|
||||||
export { useWritePortal } from "./useWritePortal";
|
export { useWritePortal } from "./useWritePortal";
|
||||||
export type { UseWritePortalResult } 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,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { TerminalView } from "@/features/terminals";
|
import { TerminalView, type TerminalInputApi } from "@/features/terminals";
|
||||||
|
import { TerminalKeyBar } from "./TerminalKeyBar";
|
||||||
|
|
||||||
interface WebAgentCellProps {
|
interface WebAgentCellProps {
|
||||||
/** Owning project id (resolved server-side by `agent.launch`). */
|
/** Owning project id (resolved server-side by `agent.launch`). */
|
||||||
@ -43,6 +44,10 @@ interface WebAgentCellProps {
|
|||||||
export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) {
|
export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) {
|
||||||
const { agent } = useGateways();
|
const { agent } = useGateways();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
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(
|
const open = useCallback(
|
||||||
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
|
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
|
||||||
@ -62,15 +67,27 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
||||||
<TerminalView
|
// a letterbox roughly 20 lines tall. It now takes 60% of the *dynamic*
|
||||||
cwd={cwd}
|
// viewport on phones (so the collapsing URL bar can't clip it) and keeps a
|
||||||
agentMode
|
// fixed, desktop-like height from `sm` up. `min-h-0` on the terminal row
|
||||||
open={open}
|
// lets it shrink inside the flex column instead of pushing the key bar off.
|
||||||
reattach={reattach}
|
<div
|
||||||
sessionId={sessionId}
|
data-testid="web-agent-cell"
|
||||||
onSessionId={setSessionId}
|
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>
|
</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 { PairingScreen } from "./PairingScreen";
|
||||||
export { WebWorkspace } from "./WebWorkspace";
|
export { WebWorkspace } from "./WebWorkspace";
|
||||||
export { WebAgentCell } from "./WebAgentCell";
|
export { WebAgentCell } from "./WebAgentCell";
|
||||||
|
export { TerminalKeyBar } from "./TerminalKeyBar";
|
||||||
|
|||||||
Reference in New Issue
Block a user