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

@ -0,0 +1,63 @@
/**
* Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`)
* only when the HTTP transport is selected (`VITE_TRANSPORT="http"`).
*
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
* cookie) drops back to pairing automatically. A best-effort "Se déconnecter"
* clears the local flag (server-side cookie revocation is B8).
*/
import { useEffect, useState } from "react";
import { Button } from "@/shared";
import { getWebSession, type WebSession } from "@/adapters/http";
import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace";
interface WebAppProps {
/** Injectable session (tests); defaults to the shared singleton. */
session?: WebSession;
}
export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired());
useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing.
return webSession.onUnauthorized(() => setPaired(false));
}, [webSession]);
function signOut(): void {
webSession.forget();
setPaired(false);
}
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">
<div className="flex items-baseline gap-2">
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
web
</span>
</div>
{paired && (
<Button variant="ghost" size="sm" onClick={signOut}>
Se déconnecter
</Button>
)}
</header>
<div className="flex flex-1 flex-col overflow-hidden">
{paired ? (
<WebWorkspace />
) : (
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
)}
</div>
</div>
);
}