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:
@ -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
|
||||
|
||||
@ -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é ».
|
||||
|
||||
@ -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
|
||||
@ -725,7 +725,7 @@
|
||||
"assignedAgentIds": [
|
||||
"a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
],
|
||||
"updatedAt": 1784202783353
|
||||
"updatedAt": 1784207736267
|
||||
},
|
||||
{
|
||||
"issueRef": "#70",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
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 } 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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -7,3 +7,4 @@ export { WebApp } from "./WebApp";
|
||||
export { PairingScreen } from "./PairingScreen";
|
||||
export { WebWorkspace } from "./WebWorkspace";
|
||||
export { WebAgentCell } from "./WebAgentCell";
|
||||
export { TerminalKeyBar } from "./TerminalKeyBar";
|
||||
|
||||
@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user