Merge feature/ticket69-mobile-responsive-client into develop (#69)

Rend le client web utilisable sur téléphone : `100dvh` + `viewport-fit=cover` et
safe-area insets (la barre d'URL rétractable masquait le bas), rangées qui
s'empilent sous 360px, et surtout une `TerminalKeyBar` (Esc/Tab/Ctrl-C/Ctrl-D/
flèches) qui rend le terminal pilotable au doigt — le clavier virtuel n'offre
aucune de ces touches, on pouvait donc parler à un agent CLI mais pas
l'interrompre.

Surface frontend-pure : aucun DTO, use case ni `LayoutTree` touché. Le chemin
desktop est inchangé (`LayoutGrid` ne passe pas `onReady`, `onReady` est
optionnel).

Vert avant merge, ré-exécuté par Git sur la base rebasée (les chiffres du carnet
ne valaient que sur `506d589`) : `npm run typecheck` exit 0 · `npx vitest run`
85 files / 774 passed, 0 échec · `VITE_TRANSPORT=http npx vite build` exit 0.

RÉSERVE — le lot 3 est livré mais NON PROUVÉ en conditions réelles.
La validation live utilisateur du 2026-07-16 (téléphone réel, via le reverse
proxy) est PARTIELLE. Couvert : le terminal s'ouvre, ce qui implique appairage +
workspace traversés, donc les lots 1 et 2 (`100dvh`, `60dvh`) validés en réel.
NON couvert : la `TerminalKeyBar` n'a pas été utilisée, et l'invariant de focus
au tap (« chaque tap annule le déplacement de focus puis refocalise xterm », sans
lequel le clavier virtuel se referme à chaque touche) n'a pas été observé sur
navigateur mobile réel. C'est le cœur du ticket. Le test manquant tient en 30 s :
lancer une commande longue, taper Ctrl-C dans la barre, vérifier que la commande
s'interrompt ET que le clavier reste ouvert.
Merge autorisé par Main sur ce constat : risque résiduel borné (frontend-pur,
desktop non atteint, non-régression prouvée). Si le test Ctrl-C échoue, il ouvre
un bug, il ne réverte pas ce merge. Jusque-là, « mergé » ne veut pas dire
« validé ».

Trou de couverture connu et assumé : jsdom n'évalue pas les media queries, les
tests sont structurels et ne prouvent ni les breakpoints, ni `dvh`, ni les safe
areas, ni le clavier virtuel. La décision d'ajouter un vrai navigateur
(Playwright) appartient à #63 « Système de test de l'UI ».

Rebase préalable sur `develop@fe0e53e` (#65 avait fait avancer la base) :
sans conflit, aucun recouvrement de fichiers, arbre `frontend/` identique bit
pour bit à l'original `6ed0087`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:16:21 +02:00
16 changed files with 485 additions and 54 deletions

View File

@ -7007,10 +7007,36 @@
"ticket_id": "36f9d4e8-fdb1-4e8d-9b35-56d36e7d20ee",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
},
"state": "running",
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784206763128,
"updatedAtMs": 1784206763128,
"updatedAtMs": 1784206996360,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784206996360,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "Merge terminé, sans conflit, strictement en local. `develop` est maintenant sur **`fe0e53e`**, working tree propre.\n\n## Pas de passe QA supplémentaire — et voici pourquoi\n\nTu m'as laissé le déclenchement à disposition ; je ne l'ai pas demandé, mais je ne me suis pas contenté de tes constats live non plus. Le carnet de #65 contenait déjà une vérif QA à exécution réelle, et **je l'ai ré-exécutée moi-même avant de merger**, puis une seconde fois sur `develop` après merge :\n\n```\ncargo test -p backend → 41 passed, 0 failed\ncargo test -p web-server → 55 passed, 0 failed\ncargo test -p app-tauri → 35 passed, 0 failed\n```\n\nChiffres identiques au carnet, 0 échec. Tes constats live (démarrage, SPA servi, contrôle d'origine, reverse proxy) couvrent le chemin d'exécution que les tests ne touchent pas ; l'ensemble satisfait la règle d'or. Deux réserves que je te remonte sans les avoir traitées : les 10 échecs `openai_compat` de `cargo test --workspace` sont **préexistants sur `506d589`** (bind réseau interdit par la sandbox), donc hors périmètre ; et le carnet signale qu'aucun test ne couvre `run_embedded().stop()` — à prendre dans #68, qui consommera ce handle.\n\n## Les trois arbitrages\n\n**1. Squash, oui.** `82e8e77` était un commit de sûreté disant explicitement « ne fige aucun contrat », et `ddbea7b` comblait précisément l'écart qu'il annonçait. Les deux n'ont de sens qu'ensemble : laisser « état intermédiaire non figé » dans `develop` y aurait inscrit durablement une affirmation devenue fausse. La branche n'avait jamais été poussée et n'était contenue par aucune autre — réécriture sans risque. J'ai vérifié que l'arbre du commit squashé est **identique bit pour bit** à `ddbea7b` (`git diff --cached ddbea7b` vide), et j'ai gardé l'historique d'origine sur `backup/ticket65-pre-squash-ddbea7b` — à toi de me dire quand le supprimer.\n\n**2. `dist-web`, d'accord avec toi.** Règle d'ignore ajoutée à côté de `frontend/dist/`, même classe : sortie de build rebuildable. Rien de ce dossier n'est entré dans l'historique.\n\n**3. L'état `.ideai/` est durable, je l'ai committé** dans un `chore(ideai)` séparé du code, comme le précédent `8e481ae`. Les tickets et `.ideai/memory/` sont le store versionné assumé (le `.gitignore` le dit noir sur blanc). Scan de secrets passé, rien trouvé.\n\nLes commits, sur `feature/ticket65-idea-serve-headless` puis mergés en `--no-ff` (pas de rebase : `develop` n'avait pas bougé, merge-base = son sommet exact) :\n\n| Commit | Contenu |\n|---|---|\n| `0e15482` | `feat(web-server)` — extraction `idea-serve` (squash) |\n| `4f57e5a` | `chore(gitignore)` — ignore `frontend/dist-web/` |\n| `5ee25d1` | `chore(ideai)` — tickets #13/#65 + #68-#70, note mémoire |\n| `fe0e53e` | merge `--no-ff` dans `develop` |\n\nBranche de feature supprimée après confirmation qu'elle était bien intégrée.\n\n**Un point que je te laisse arbitrer** : `.ideai/background-tasks/*.json` est suivi et je l'ai committé par cohérence avec le précédent, mais c'est un journal d'exécution machine-local (7000+ lignes, avec des `stdout_tail` entiers) qui ressemble plus à `.ideai/conversations/` — déjà ignoré — qu'à de l'état durable. Le désuivre changerait ce qui est versionné : c'est une décision d'architecture, pas de topologie git, donc elle revient à Architect. Ça vaut un ticket.\n\n`develop` est en avance de 4 commits sur `origin/develop`. Aucune action sortante : pas de push, pas de PR.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "176eb370-fbe9-4fab-96e4-0d0a813ec2c9",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"ticket_id": "54fd5f2e-35d2-465f-b224-a918ec99189a",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
},
"state": "running",
"wakePolicy": "recordOnly",
"createdAtMs": 1784207556306,
"updatedAtMs": 1784207556306,
"deadlineMs": null,
"result": null,
"completionDelivered": false

View File

@ -1,13 +1,13 @@
---
issueRef: "#69"
version: 9
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784202783353
version: 11
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
updatedAt: 1784207736267
---
# Ticket #69 — Adaptibilité client téléphone (carnet de chantier)
Statut : **QA — en attente de validation live utilisateur sur un VRAI téléphone**.
Branche : `feature/ticket69-mobile-responsive-client` (base `develop` @ `506d589`). Commits : `e22ea5b` (shell), `8f15ad6` (terminal + tests), `6ed0087` (fix typecheck). Frontend uniquement — vérifié.
Statut : **validation live utilisateur PARTIELLE effectuée — mergé dans `develop` avec réserve explicite sur le lot 3**.
Branche : `feature/ticket69-mobile-responsive-client`, **rebasée sur `develop` @ `fe0e53e`** (base d'origine `506d589`). Commits après rebase : `48b8853` (shell), `62ecefe` (terminal + tests), `f0f2f3b` (fix typecheck) — anciennement `e22ea5b`/`8f15ad6`/`6ed0087`. Arbre `frontend/` identique bit pour bit avant/après rebase (`git diff 6ed0087 HEAD -- frontend` vide) ; historique d'origine conservé sur `backup/ticket69-pre-rebase-6ed0087`. Frontend uniquement — vérifié.
## Cadrage Architect
Ticket **frontend-pur** : aucun changement DTO, use case, ni modèle `LayoutTree`. Contrats HTTP/WS et layout/terminal inchangés. Mais PAS du « CSS pur ».
@ -29,13 +29,28 @@ Le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches : on pouvait taper u
- **CSS mobile confirmé DANS le bundle** (les valeurs arbitraires Tailwind avec `env()` no-opent en silence) : `100dvh`, `60dvh`, les 4 `safe-area-inset-*`, `viewport-fit=cover`, `.h-\[60dvh\]{height:60dvh}`.
- Non-régression desktop : `LayoutGrid` ne passe pas `onReady` ; chemin desktop inchangé.
- Rendu réel Chrome headless 360×780 : pairing tient sans débordement horizontal.
- **Ré-exécutée par Git sur la base rebasée `fe0e53e` avant merge** (les chiffres du carnet ne valaient que sur `506d589`) : `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed**, 0 échec · `VITE_TRANSPORT=http npx vite build` exit 0. Identique — le rebase n'a rien cassé.
## Réserve ASSUMÉE — trou de couverture réel
**jsdom n'évalue PAS les media queries** : les tests sont structurels, pas visuels. Ils ne prouvent NI le rendu aux breakpoints, NI l'effet réel de `dvh`, NI les safe areas, NI le clavier virtuel/focus sur navigateur mobile. QA : « acceptable pour merger avec réserve explicite », pas rouge. **C'est exactement ce que la validation utilisateur doit couvrir.**
Workspace + keybar non vérifiés visuellement à 360px (demande un serveur appairé + PTY vivant = territoire #65) — angle mort restant.
## Écart de périmètre à arbitrer (Playwright)
Lot 4 demandait « Playwright ou équivalent ». DevFrontend a livré l'équivalent (vitest/jsdom) et REFUSÉ d'installer Playwright unilatéralement (binaires navigateur + surface CI = décision de dépendance qui remonte à Architect/Main). Avis QA : non bloquant pour ce lot, mais vitest/jsdom **n'est pas un équivalent complet** pour un ticket dont le risque principal est visuel mobile. ⇒ **Follow-up à décider** : ticket « tests de rendu réel aux breakpoints » si le projet accepte une dépendance navigateur/CI.
## Validation live utilisateur — PÉRIMÈTRE EXACT (2026-07-16)
Effectuée par l'utilisateur sur un **vrai téléphone**, contre le serveur `idea-serve` exposé via le reverse proxy `https://idea.anthonybouteiller.ovh`.
**Couvert — verdict utilisateur : OK.** « Le terminal s'ouvre correctement pour le téléphone. » Atteindre le terminal implique d'avoir traversé l'appairage **puis** le workspace : cela valide en réel le shell (lot 1) et la mise en page à taille de téléphone (lot 2), y compris le `100dvh` et le `60dvh` de la cellule terminal, que jsdom ne pouvait pas prouver.
**NON couvert — angle mort qui subsiste.** Le **lot 3 n'a pas été exercé** : la `TerminalKeyBar` (Esc/Tab/Ctrl-C/Ctrl-D/flèches) n'a pas été utilisée, et le comportement de focus au tap — l'invariant « chaque tap annule le déplacement de focus puis refocalise xterm », sans lequel le clavier virtuel se referme à chaque touche — n'a **pas** été observé sur un navigateur mobile réel. Or c'est le cœur du ticket : le carnet identifie le terminal, pas le layout, comme le vrai blocage.
Le test qui fermerait ce trou tient en 30 s : lancer une commande longue, taper **Ctrl-C** dans la barre de touches, vérifier (a) que la commande s'interrompt et (b) que le clavier virtuel reste ouvert.
**Arbitrage Main : merge autorisé avec cette réserve écrite, pas effacée.** Le risque résiduel est borné (surface frontend-pure, desktop non atteint, non-régression prouvée) et ne justifie pas de bloquer le sprint. Mais tant que le test Ctrl-C n'est pas fait, **le lot 3 est livré et non prouvé en conditions réelles** — à ne pas présenter comme validé.
**Report de la réserve dans l'historique git (Git, 2026-07-16)** : la réserve est recopiée dans le corps du commit de merge de `develop`. Un carnet peut être réécrit ; un message de commit mergé, non. Le fait « lot 3 non prouvé en réel au moment du merge » est donc daté et infalsifiable, indépendamment de ce fichier.
## Écart de périmètre — Playwright : TRANCHÉ, reporté vers #63
Lot 4 demandait « Playwright ou équivalent ». DevFrontend a livré l'équivalent (vitest/jsdom) et REFUSÉ d'installer Playwright unilatéralement (binaires navigateur + surface CI = décision de dépendance qui remonte à Architect/Main) — refus fondé. QA : non bloquant pour ce lot.
**Décision Main (2026-07-16) : ne bloque pas #69 et ne donne PAS lieu à un ticket neuf.** La décision de dépendance appartient à **#63 « Système de test de l'UI »**, déjà ouvert, où elle sera tranchée avec la stratégie de test UI dans son ensemble plutôt qu'au coin d'un sprint serveur/client. Rationnel conservé : Playwright rend un vrai navigateur (media queries, `dvh`, safe areas évalués pour de vrai) et aurait attrapé automatiquement les deux bugs de classe « CSS silencieusement absent du bundle » corrigés ici ; il **n'émule** cependant qu'un téléphone et ne remplace pas la validation manuelle du clavier virtuel — sa valeur est d'empêcher la régression silencieuse entre deux validations manuelles.
## Prochaine étape
Validation live utilisateur sur un vrai téléphone (build `VITE_TRANSPORT=http`, servi par `idea --serve`/`idea-serve`, accès LAN). Si OK → Git merge dans develop (Git passera #69 avant #65 si les deux sont prêts : petit, frontend-pur, faible risque).
Merge fait par Git (rebase + `--no-ff` dans `develop`, sans conflit). **Le test Ctrl-C sur téléphone réel reste ouvert** : s'il échoue, il ouvre un bug, il ne réverte pas le ticket. Le passage du ticket à un statut terminal appartient à Main : tant que le lot 3 n'est pas prouvé en réel, « mergé » ≠ « validé ».

View File

@ -8,9 +8,9 @@ sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
createdAt: 1784193391258
updatedAt: 1784202783353
version: 9
updatedAt: 1784207736267
version: 11
---
J'aimerais un format vertical qui rende l'app client compatible téléphone

View File

@ -725,7 +725,7 @@
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784202783353
"updatedAt": 1784207736267
},
{
"issueRef": "#70",

View File

@ -2,7 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- `viewport-fit=cover` (#69) lets the web client paint under a notch and
exposes the `env(safe-area-inset-*)` values the shell pads with. Zoom is
deliberately left unrestricted (no `maximum-scale`/`user-scalable=no`):
pinch-zoom is an accessibility affordance. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>IdeA</title>
</head>
<body>

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

@ -53,7 +53,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
}
return (
<div className="flex h-full items-center justify-center bg-canvas p-6">
<div className="flex h-full items-center justify-center overflow-y-auto bg-canvas p-4 sm:p-6">
<Panel className="w-full max-w-sm">
<form onSubmit={submit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
@ -74,6 +74,12 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
placeholder="p. ex. 4821-93"
autoFocus
autoComplete="one-time-code"
// #69 — phones: summon the numeric-ish keypad for a pairing code
// and keep the OS from "helpfully" capitalising/correcting it.
inputMode="numeric"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
disabled={busy}
invalid={!!error}
/>

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

@ -49,9 +49,12 @@ export function WebApp({ session }: WebAppProps = {}) {
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<header
data-testid="web-header"
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:px-6"
>
<div className="flex items-baseline gap-2">
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
<h1 className="text-base font-semibold tracking-tight sm:text-lg">IdeA</h1>
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
web
</span>

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

@ -75,7 +75,10 @@ export function WebWorkspace() {
);
return (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div
data-testid="web-workspace"
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
>
<ReconnectBanner />
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
@ -223,7 +226,11 @@ function AgentLiveRow({
return (
<li className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2">
{/* #69 — at 360px the name + two badges + the button no longer fit on one
line, so the name takes its own row on phones and the status cluster
wraps under it. From `sm` up this collapses back to the original
single-line desktop layout. */}
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs">
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
@ -311,29 +318,36 @@ function WebBackgroundTaskRow({
return (
<li className="min-w-0 text-xs text-muted">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
{TASK_STATUS_LABEL[task.status]}
</span>
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
<Button
size="sm"
variant="ghost"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
>
Retry
</Button>
{/* #69 — status + kind + Cancel + Retry overflow a phone row. On narrow
screens the status/kind pair keeps the first line and the two actions
wrap onto a second, right-aligned one; `sm` restores the single row. */}
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
{TASK_STATUS_LABEL[task.status]}
</span>
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
disabled={!canCancel || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
>
Cancel
</Button>
<Button
size="sm"
variant="ghost"
disabled={!canRetry || actionBusy}
loading={actionBusy}
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
>
Retry
</Button>
</div>
</div>
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
</li>

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

View File

@ -60,12 +60,28 @@
height: 100%;
}
/* #69 — phone viewports. `height: 100%` resolves against the *large* viewport,
so a mobile browser's collapsing URL bar leaves the last ~60px of the app
under the toolbar and makes xterm's fit() overshoot. `100dvh` tracks the
*dynamic* viewport instead. Desktop is unaffected: there the dynamic and
large viewports are identical, so this resolves to the same height. */
@supports (height: 100dvh) {
html,
body,
#root {
height: 100dvh;
}
}
body {
margin: 0;
background-color: var(--color-canvas);
color: var(--color-content);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
/* Phones: never rubber-band the whole document — the app owns its own
scroll containers, and an overscrolling body detaches the fixed shell. */
overscroll-behavior-y: none;
}
/* A consistent focus ring across the kit (keyboard accessibility). */