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,96 @@
/**
* Web pairing screen — ticket #13, lot F2.
*
* Shown by {@link WebApp} when the client is not paired. The user types the code
* the server printed at first launch; on submit we `POST /api/pair {code}` via
* the {@link WebSession}. On success the server sets the HttpOnly session cookie
* and we advance to the workspace; a wrong code shows a clear message.
*
* Transport-neutral at the component seam: it talks to the injected
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
* stays green). Pure web-only UI — desktop never mounts it.
*/
import { useState, type FormEvent } from "react";
import type { GatewayError } from "@/domain";
import { Button, Field, Input, Panel } from "@/shared";
import type { WebSession } from "@/adapters/http";
interface PairingScreenProps {
/** The shared web session performing the `POST /api/pair` handshake. */
session: WebSession;
/** Called once pairing succeeds (cookie set) so the app can advance. */
onPaired: () => void;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
await session.pair(trimmed);
onPaired();
} catch (err) {
setError(describe(err));
} finally {
setBusy(false);
}
}
return (
<div className="flex h-full items-center justify-center bg-canvas 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">
<h1 className="text-lg font-semibold tracking-tight">Appairer cet appareil</h1>
<p className="text-sm text-muted">
Saisissez le code affiché par le serveur IdeA pour connecter ce
navigateur.
</p>
</div>
<Field label="Code d'appairage">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="p. ex. 4821-93"
autoFocus
autoComplete="one-time-code"
disabled={busy}
invalid={!!error}
/>
)}
</Field>
{error && (
<p role="alert" data-testid="pairing-error" className="text-sm text-danger">
{error}
</p>
)}
<Button type="submit" disabled={busy || code.trim().length === 0}>
{busy ? "Appairage…" : "Appairer"}
</Button>
</form>
</Panel>
</div>
);
}

View File

@ -0,0 +1,120 @@
/**
* F2 — the web client routing + pairing flow:
* - not paired ⇒ pairing screen; wrong code ⇒ clear error, stays on pairing;
* - successful pair ⇒ read-only workspace (project list + open + snapshot);
* - a 401 (session.notifyUnauthorized) ⇒ back to pairing.
*/
import { describe, it, expect } from "vitest";
import { act, fireEvent, render, screen, waitFor } 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";
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 () => "{}",
});
const rejectFetch: FetchLike = async () => ({
ok: false,
status: 401,
json: async () => ({ code: "INVALID", message: "bad" }),
text: async () => "{}",
});
/** Mock gateways seeded with one project + a work-state snapshot. */
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;
}
function renderWebApp(session: WebSession, gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
describe("WebApp pairing routing", () => {
it("shows the pairing screen when not paired", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
renderWebApp(session, await seededGateways());
expect(screen.getByText("Appairer cet appareil")).toBeTruthy();
expect(screen.queryByText("Projets")).toBeNull();
});
it("advances to the read-only workspace after a successful pair", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
renderWebApp(session, await seededGateways());
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "4821-93" } });
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
expect(await screen.findByText("Projets")).toBeTruthy();
expect(await screen.findByText("Demo")).toBeTruthy();
expect(session.isPaired()).toBe(true);
});
it("shows a clear error on a wrong code and stays on pairing", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: rejectFetch, store: memStore() });
renderWebApp(session, await seededGateways());
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "nope" } });
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
expect(await screen.findByTestId("pairing-error")).toBeTruthy();
expect(screen.queryByText("Projets")).toBeNull();
expect(session.isPaired()).toBe(false);
});
it("opens a project read-only and renders the work-state snapshot", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
renderWebApp(session, await seededGateways());
// Workspace is shown directly (already paired); open the seeded project.
fireEvent.click(await screen.findByText("Demo"));
const snapshot = await screen.findByTestId("web-workstate");
expect(snapshot.textContent).toContain("Archi");
expect(snapshot.textContent).toContain("idle");
});
it("returns to pairing when the session reports unauthorized (401)", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
renderWebApp(session, await seededGateways());
expect(await screen.findByText("Projets")).toBeTruthy();
act(() => session.notifyUnauthorized());
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
expect(screen.queryByText("Projets")).toBeNull();
});
});

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

View File

@ -0,0 +1,156 @@
/**
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
*
* The minimal post-pairing surface: list the projects, open one **read-only**,
* and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no
* mutation — every call goes through the existing transport-neutral gateways
* (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`.
*
* It reuses the frozen read-model types (`ProjectWorkState`) and only calls the
* commands B4 puts on the read-only allowlist: `list_projects`, `open_project`,
* `get_project_work_state`. A deliberately small surface — the full IDE (layout,
* agents, terminals) is out of scope until the streaming lots.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Panel, Spinner } from "@/shared";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function WebWorkspace() {
const { project, workState } = useGateways();
const [projects, setProjects] = useState<Project[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
const refresh = useCallback(async () => {
setError(null);
try {
setProjects(await project.listProjects());
} catch (e) {
setError(describe(e));
}
}, [project]);
useEffect(() => {
void refresh();
}, [refresh]);
const openReadOnly = useCallback(
async (projectId: string) => {
setError(null);
setLoadingSnapshot(true);
setOpenId(projectId);
setSnapshot(null);
try {
// Read-only: open resolves the project server-side, then we read the
// live/work-state snapshot. No layout/agents/PTY are mounted.
await project.openProject(projectId);
setSnapshot(await workState.getProjectWorkState(projectId));
} catch (e) {
setError(describe(e));
} finally {
setLoadingSnapshot(false);
}
},
[project, workState],
);
return (
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
Rafraîchir
</Button>
</div>
{error && (
<Panel className="border-danger/40">
<p className="text-sm text-danger">{error}</p>
</Panel>
)}
{projects === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des projets
</span>
) : projects.length === 0 ? (
<p className="text-sm text-muted">Aucun projet.</p>
) : (
<ul className="flex flex-col gap-2" data-testid="web-project-list">
{projects.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => void openReadOnly(p.id)}
aria-pressed={openId === p.id}
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
>
<span className="text-sm font-medium text-content">{p.name}</span>
<span className="text-xs text-faint">{p.root}</span>
</button>
</li>
))}
</ul>
)}
{openId && (
<Panel className="mt-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">
État (lecture seule)
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
read-only
</span>
</h3>
{loadingSnapshot && <Spinner size={12} />}
</div>
{snapshot ? (
<WorkStateSnapshot snapshot={snapshot} />
) : loadingSnapshot ? (
<p className="text-xs text-muted">Chargement de l'état</p>
) : (
<p className="text-xs text-muted">Aucun état disponible.</p>
)}
</Panel>
)}
</div>
);
}
/** Pure render of the read-only work-state snapshot. */
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
if (snapshot.agents.length === 0) {
return <p className="text-xs text-muted">Aucun agent actif.</p>;
}
return (
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
{snapshot.agents.map((a) => (
<li key={a.agentId} className="flex items-center justify-between text-xs">
<span className="text-content">{a.name}</span>
<span className="flex items-center gap-2 text-faint">
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
<span
className={
a.busy.state === "busy" ? "text-warning" : "text-success"
}
>
{a.busy.state === "busy" ? "busy" : "idle"}
</span>
</span>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,8 @@
/**
* Web client feature (ticket #13, lot F2) — the read-only browser surface used
* when the HTTP transport is selected. Public entry is {@link WebApp}.
*/
export { WebApp } from "./WebApp";
export { PairingScreen } from "./PairingScreen";
export { WebWorkspace } from "./WebWorkspace";