feat(frontend): client web read-only pairing + snapshot état (#13)

Lot F2 du chantier server/client mode : client web read-only complétant le
premier incrément livrable — pairing, liste des projets, ouverture et
snapshot de l'état, sans PTY.

- frontend/src/adapters/http/webSession.ts : session web (pairing/cookie).
- frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index.
- Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401).
- Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans
  httpInvoker.test.ts.

Validé : frontend 736 tests verts, build vert, garde no-direct-invoke
verte, contrat B4↔F2 aligné, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:21:35 +02:00
parent fa353f6c0b
commit e500e31663
11 changed files with 768 additions and 3 deletions

View File

@ -30,6 +30,8 @@ export type FetchLike = (
headers?: Record<string, string>;
body?: string;
signal?: AbortSignal;
/** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */
credentials?: "same-origin" | "include" | "omit";
},
) => Promise<{
ok: boolean;
@ -47,6 +49,12 @@ export interface HttpInvokerConfig {
token?: string;
/** Injected fetch (defaults to global `fetch`). */
fetchImpl?: FetchLike;
/**
* Called when the backend answers `401` (the session cookie is missing or
* expired). F2 wires this to the web session so the app routes back to the
* pairing screen. The `GatewayError` is still thrown to the caller.
*/
onUnauthorized?: () => void;
}
/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
@ -71,10 +79,12 @@ export class HttpInvoker {
private readonly baseUrl: string;
private readonly token?: string;
private readonly fetchImpl: FetchLike;
private readonly onUnauthorized?: () => void;
constructor(config: HttpInvokerConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.token = config.token;
this.onUnauthorized = config.onUnauthorized;
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
// to the minimal shape used here.
this.fetchImpl =
@ -98,6 +108,9 @@ export class HttpInvoker {
method: "POST",
headers,
body: JSON.stringify({ command, args }),
// Same-origin so the HttpOnly session cookie is sent automatically
// (ticket #13: no secret in the URL/headers from JS).
credentials: "same-origin",
});
} catch (networkError) {
const err: GatewayError = {
@ -108,6 +121,9 @@ export class HttpInvoker {
}
if (!res.ok) {
// A 401 means the session cookie is missing/expired: signal the app to
// route back to pairing (the error is still thrown to the caller).
if (res.status === 401) this.onUnauthorized?.();
// Preserve the backend `ErrorDto` when present; fall back to the status.
let parsed: unknown;
try {