From 48b88532144fbb18febcb3cd964929fddeb9f116 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 13:27:56 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(frontend):=20shell=20web=20responsive?= =?UTF-8?q?=20pour=20viewport=20t=C3=A9l=C3=A9phone=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lots 1 et 2 du ticket #69 : rendre le client web (#13) utilisable sur un téléphone. Le client web n'a jamais eu de docks ni de fenêtres flottantes — il est déjà une colonne verticale unique — donc le lot 2 se réduit aux rangées qui débordaient réellement à 360px. - `100dvh` sur html/body/#root : `height: 100%` se résout sur le *large* viewport, donc la barre d'URL rétractable d'un navigateur mobile masquait le bas de l'app. Desktop inchangé (dynamic == large viewport). - `viewport-fit=cover` + padding `env(safe-area-inset-*)` sur le header et le workspace : l'app peut peindre sous une encoche sans perdre ses contrôles. Le zoom reste libre (accessibilité). - Padding responsive (`p-4 sm:p-6`) : 48px de gouttière sur 360px, c'était 13% de la largeur. - `AgentLiveRow` et la rangée de tâche de fond empilent leurs contrôles sur téléphone (nom / badges / actions) et retrouvent leur ligne unique dès `sm` — à 360px les boutons Cancel+Retry ne tenaient pas. - Code d'appairage : `inputMode="numeric"`, pas d'autocapitalisation. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 9 ++- frontend/src/features/web/PairingScreen.tsx | 8 ++- frontend/src/features/web/WebApp.tsx | 7 ++- frontend/src/features/web/WebWorkspace.tsx | 64 +++++++++++++-------- frontend/src/shared/styles/theme.css | 16 ++++++ 5 files changed, 75 insertions(+), 29 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 1c0d567..61688b2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,14 @@ - + + IdeA diff --git a/frontend/src/features/web/PairingScreen.tsx b/frontend/src/features/web/PairingScreen.tsx index 04e0685..32bb704 100644 --- a/frontend/src/features/web/PairingScreen.tsx +++ b/frontend/src/features/web/PairingScreen.tsx @@ -53,7 +53,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) { } return ( -
+
@@ -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} /> diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index b24e072..c222a49 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -49,9 +49,12 @@ export function WebApp({ session }: WebAppProps = {}) { return (
-
+
-

IdeA

+

IdeA

web diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 3d978d2..ecce05b 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -75,7 +75,10 @@ export function WebWorkspace() { ); return ( -
+

Projets

@@ -223,7 +226,11 @@ function AgentLiveRow({ return (
  • -
    + {/* #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. */} +
    {agent.name} @@ -311,29 +318,36 @@ function WebBackgroundTaskRow({ return (
  • -
    - - {TASK_STATUS_LABEL[task.status]} - - {task.kind} - - + {/* #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. */} +
    +
    + + {TASK_STATUS_LABEL[task.status]} + + {task.kind} +
    +
    + + +
    {message &&

    {message}

    }
  • diff --git a/frontend/src/shared/styles/theme.css b/frontend/src/shared/styles/theme.css index e48ebb0..8775763 100644 --- a/frontend/src/shared/styles/theme.css +++ b/frontend/src/shared/styles/theme.css @@ -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). */ From 62ecefe1e8c91d82d91480b8583e0bc6fd8de651 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 13:28:09 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(frontend):=20terminal=20pilotable=20au?= =?UTF-8?q?=20doigt=20sur=20t=C3=A9l=C3=A9phone=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/features/terminals/TerminalView.tsx | 42 +++++++ frontend/src/features/terminals/index.ts | 1 + .../src/features/web/TerminalKeyBar.test.tsx | 76 ++++++++++++ frontend/src/features/web/TerminalKeyBar.tsx | 90 ++++++++++++++ frontend/src/features/web/WebAgentCell.tsx | 37 ++++-- frontend/src/features/web/WebMobile.test.tsx | 117 ++++++++++++++++++ frontend/src/features/web/index.ts | 1 + 7 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 frontend/src/features/web/TerminalKeyBar.test.tsx create mode 100644 frontend/src/features/web/TerminalKeyBar.tsx create mode 100644 frontend/src/features/web/WebMobile.test.tsx diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 20dd4cc..fede0f4 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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(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); }; diff --git a/frontend/src/features/terminals/index.ts b/frontend/src/features/terminals/index.ts index ddb8486..1dba938 100644 --- a/frontend/src/features/terminals/index.ts +++ b/frontend/src/features/terminals/index.ts @@ -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"; diff --git a/frontend/src/features/web/TerminalKeyBar.test.tsx b/frontend/src/features/web/TerminalKeyBar.test.tsx new file mode 100644 index 0000000..1703562 --- /dev/null +++ b/frontend/src/features/web/TerminalKeyBar.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + + 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(); + + expect(screen.getByRole("toolbar", { name: "Touches du terminal" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/web/TerminalKeyBar.tsx b/frontend/src/features/web/TerminalKeyBar.tsx new file mode 100644 index 0000000..2b63ce6 --- /dev/null +++ b/frontend/src/features/web/TerminalKeyBar.tsx @@ -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 ( +
    + {KEYS.map((key) => ( + + ))} +
    + ); +} diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 992c628..45a4fb7 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -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(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(null); const open = useCallback( (options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => @@ -62,15 +67,27 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr ); return ( -
    - + // #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. +
    +
    + +
    +
    ); } diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx new file mode 100644 index 0000000..40ed361 --- /dev/null +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -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(); + 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 { + 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( + + + , + ); +} + +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"); + }); +}); diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts index ad46ed4..f3d5594 100644 --- a/frontend/src/features/web/index.ts +++ b/frontend/src/features/web/index.ts @@ -7,3 +7,4 @@ export { WebApp } from "./WebApp"; export { PairingScreen } from "./PairingScreen"; export { WebWorkspace } from "./WebWorkspace"; export { WebAgentCell } from "./WebAgentCell"; +export { TerminalKeyBar } from "./TerminalKeyBar"; From f0f2f3b497f9bfa26345cc02f252d69e7727ac88 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 13:29:00 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(frontend):=20retirer=20l'import=20`vi`?= =?UTF-8?q?=20inutilis=C3=A9=20du=20test=20de=20la=20key=20bar=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run build` est `tsc --noEmit && vite build` : un import non utilisé casse le build (TS6133), pas seulement le lint. Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/web/TerminalKeyBar.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/web/TerminalKeyBar.test.tsx b/frontend/src/features/web/TerminalKeyBar.test.tsx index 1703562..6661d75 100644 --- a/frontend/src/features/web/TerminalKeyBar.test.tsx +++ b/frontend/src/features/web/TerminalKeyBar.test.tsx @@ -5,7 +5,7 @@ * 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 { describe, it, expect } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import type { TerminalInputApi } from "@/features/terminals"; From 24f5e859743587ddcd211d21eff183eee70e5aa7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 15:15:45 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(ideai):=20carnet=20de=20#69=20?= =?UTF-8?q?=E2=80=94=20validation=20live=20partielle=20et=20r=C3=A9serve?= =?UTF-8?q?=20du=20lot=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit État `.ideai/` du chantier client téléphone, séparé du code applicatif conformément aux précédents `5ee25d1` / `8e481ae`. - Carnet #69 : périmètre exact de la validation live utilisateur sur téléphone réel (lots 1 et 2 validés en réel via l'appairage puis le workspace ; lot 3 — `TerminalKeyBar` et invariant de focus — NON exercé), arbitrage Main autorisant le merge avec réserve écrite, et report de l'écart Playwright vers #63 plutôt que vers un ticket neuf. - Faits de topologie mis à jour après rebase sur `develop@fe0e53e` : les SHA cités (`e22ea5b`/`8f15ad6`/`6ed0087`) n'existaient plus sur aucune branche. - Journal des tâches de fond : complétions enregistrées. Co-Authored-By: Claude Opus 4.8 --- .../97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json | 30 +++++++++++++++-- .ideai/tickets/69/carnet.md | 33 ++++++++++++++----- .ideai/tickets/69/issue.md | 6 ++-- .ideai/tickets/index.json | 2 +- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index 4e1961c..0d04272 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -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 diff --git a/.ideai/tickets/69/carnet.md b/.ideai/tickets/69/carnet.md index dbdf33c..5b32a43 100644 --- a/.ideai/tickets/69/carnet.md +++ b/.ideai/tickets/69/carnet.md @@ -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é ». diff --git a/.ideai/tickets/69/issue.md b/.ideai/tickets/69/issue.md index da8a444..93c01cc 100644 --- a/.ideai/tickets/69/issue.md +++ b/.ideai/tickets/69/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 0390165..3a62a1a 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -725,7 +725,7 @@ "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784202783353 + "updatedAt": 1784207736267 }, { "issueRef": "#70",