diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index 141eedc..dcbbe59 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -77,6 +77,29 @@ describe("HttpInvoker", () => { }); }); + it("fires onUnauthorized on a 401 (and still throws)", async () => { + const { fetchImpl } = fakeFetch( + { code: "UNAUTHENTICATED", message: "no session" }, + { ok: false, status: 401 }, + ); + const onUnauthorized = vi.fn(); + const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized }); + + await expect(http.invoke("list_projects")).rejects.toMatchObject({ + code: "UNAUTHENTICATED", + }); + expect(onUnauthorized).toHaveBeenCalledOnce(); + }); + + it("does not fire onUnauthorized on a non-401 error", async () => { + const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 }); + const onUnauthorized = vi.fn(); + const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized }); + + await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy(); + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => { const fetchImpl: FetchLike = async () => { throw new Error("boom"); diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index 162d869..9db8ec1 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -30,6 +30,8 @@ export type FetchLike = ( headers?: Record; 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 { diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index b3a7d9b..98c8070 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -18,6 +18,7 @@ import type { Gateways } from "@/ports"; import { LocalStorageUiPreferencesGateway } from "../uiPreferences"; import { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; +import { getWebSession } from "./webSession"; import { HttpConversationGateway, HttpEmbedderGateway, @@ -77,7 +78,13 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): { */ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways { const { baseUrl, wsUrl } = resolveEndpoints(config); - const http = new HttpInvoker({ baseUrl, token: config.token }); + // Route 401s back to the pairing screen via the shared web session (F2). + const session = getWebSession(); + const http = new HttpInvoker({ + baseUrl, + token: config.token, + onUnauthorized: () => session.notifyUnauthorized(), + }); const ws = new WsLiveClient({ wsUrl, token: config.token }); return { @@ -108,3 +115,4 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway export { HttpInvoker } from "./httpInvoker"; export { WsLiveClient } from "./wsLiveClient"; +export { WebSession, getWebSession, setWebSessionForTests } from "./webSession"; diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts new file mode 100644 index 0000000..afef6d2 --- /dev/null +++ b/frontend/src/adapters/http/webSession.test.ts @@ -0,0 +1,92 @@ +/** + * F2 — the web session: pairing handshake (success marks the flag; wrong code + * rejects with a clear message), and the 401 → unauthorized transition that + * clears the flag and notifies subscribers. + */ +import { describe, it, expect, vi } from "vitest"; + +import type { FetchLike } from "./httpInvoker"; +import { WebSession, type FlagStore } from "./webSession"; + +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), + }; +} + +function fetchReturning(status: number, body: unknown): { + fetchImpl: FetchLike; + calls: { url: string; init: unknown }[]; +} { + const calls: { url: string; init: unknown }[] = []; + const fetchImpl: FetchLike = async (url, init) => { + calls.push({ url, init }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; + }; + return { fetchImpl, calls }; +} + +describe("WebSession pairing", () => { + it("POSTs the code to /api/pair and marks the flag on success", async () => { + const store = memStore(); + const { fetchImpl, calls } = fetchReturning(200, { ok: true }); + const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); + + expect(session.isPaired()).toBe(false); + await session.pair("4821-93"); + + expect(session.isPaired()).toBe(true); + expect(calls[0].url).toBe("https://host/api/pair"); + expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" }); + }); + + it("rejects a wrong code with a clear message and stays unpaired", async () => { + const store = memStore(); + const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" }); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); + + await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" }); + expect(session.isPaired()).toBe(false); + }); + + it("falls back to a default message when the server sends no body", async () => { + const { fetchImpl } = fetchReturning(403, undefined); + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); + + await expect(session.pair("x")).rejects.toMatchObject({ + code: "INVALID_PAIRING_CODE", + message: "Code d'appairage invalide.", + }); + }); +}); + +describe("WebSession unauthorized handling", () => { + it("clears the flag and notifies subscribers on notifyUnauthorized", () => { + const store = memStore(); + const session = new WebSession({ baseUrl: "https://host", store }); + session.markPaired(); + const handler = vi.fn(); + session.onUnauthorized(handler); + + session.notifyUnauthorized(); + + expect(session.isPaired()).toBe(false); + expect(handler).toHaveBeenCalledOnce(); + }); + + it("forget() clears the flag (best-effort local sign-out)", () => { + const store = memStore(); + const session = new WebSession({ baseUrl: "https://host", store }); + session.markPaired(); + session.forget(); + expect(session.isPaired()).toBe(false); + }); +}); diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts new file mode 100644 index 0000000..810e5f2 --- /dev/null +++ b/frontend/src/adapters/http/webSession.ts @@ -0,0 +1,175 @@ +/** + * Web client session (pairing + auth routing) — ticket #13, lot F2. + * + * The real authentication material is a session cookie set by the server at + * pairing (`POST /api/pair {code}` → `Set-Cookie: … HttpOnly; Secure; + * SameSite=Strict`). Because it is **HttpOnly** the browser sends it + * automatically on same-origin requests but JS can never read it. So this module + * keeps only a lightweight, JS-visible **"paired" flag** (in `localStorage`) to + * decide which screen to render — never the secret itself. + * + * On a `401` from any `/api/invoke`, the {@link HttpInvoker} calls + * {@link WebSession.notifyUnauthorized}, which clears the flag and notifies + * subscribers so the app routes back to the pairing screen. "Forget" is a + * best-effort local clear (server-side cookie revocation is B8). + * + * Lives in `src/adapters/**`; touches no `@tauri-apps/api`. + */ + +import type { GatewayError, Unsubscribe } from "@/domain"; +import type { FetchLike } from "./httpInvoker"; + +const PAIRED_FLAG_KEY = "idea.web.paired"; + +/** Minimal `localStorage`-like surface, injectable for tests. */ +export interface FlagStore { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** Configuration for a {@link WebSession}. */ +export interface WebSessionConfig { + /** Backend base URL (no trailing slash). Defaults to the page origin. */ + baseUrl?: string; + /** Injected fetch (defaults to global `fetch`, with same-origin credentials). */ + fetchImpl?: FetchLike; + /** Injected flag store (defaults to `window.localStorage`, else in-memory). */ + store?: FlagStore; +} + +/** In-memory fallback when `localStorage` is unavailable (SSR/tests). */ +function memoryStore(): 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), + }; +} + +function defaultStore(): FlagStore { + try { + if (typeof window !== "undefined" && window.localStorage) { + return window.localStorage; + } + } catch { + /* access can throw in a sandboxed iframe; fall through */ + } + return memoryStore(); +} + +function defaultBaseUrl(): string { + return typeof window !== "undefined" && window.location + ? window.location.origin + : "http://localhost"; +} + +/** + * The web session state machine. One instance is shared between the pairing UI + * and the HTTP invoker (via the module singleton below). + */ +export class WebSession { + private readonly baseUrl: string; + private readonly fetchImpl: FetchLike; + private readonly store: FlagStore; + private readonly unauthorizedHandlers = new Set<() => void>(); + + constructor(config: WebSessionConfig = {}) { + this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, ""); + this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + this.store = config.store ?? defaultStore(); + } + + /** Whether the client believes it is paired (a session cookie should exist). */ + isPaired(): boolean { + return this.store.getItem(PAIRED_FLAG_KEY) === "1"; + } + + /** Records the paired flag (called after a successful pair). */ + markPaired(): void { + this.store.setItem(PAIRED_FLAG_KEY, "1"); + } + + /** Best-effort local sign-out: clears the flag (server revocation is B8). */ + forget(): void { + this.store.removeItem(PAIRED_FLAG_KEY); + } + + /** + * Performs the pairing handshake: `POST /api/pair {code}`. On success the + * server sets the session cookie and this records the paired flag. A wrong code + * rejects with a {@link GatewayError} carrying a clear message. + */ + async pair(code: string): Promise { + let res: Awaited>; + try { + res = await this.fetchImpl(`${this.baseUrl}/api/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + } catch (networkError) { + const err: GatewayError = { + code: "TRANSPORT_ERROR", + message: `Impossible de joindre le serveur : ${String(networkError)}`, + }; + throw err; + } + + if (!res.ok) { + let parsed: unknown; + try { + parsed = await res.json(); + } catch { + parsed = undefined; + } + const message = + parsed && typeof parsed === "object" && "message" in parsed + ? String((parsed as GatewayError).message) + : res.status === 401 || res.status === 403 + ? "Code d'appairage invalide." + : `Échec de l'appairage (HTTP ${res.status}).`; + const err: GatewayError = { + code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED", + message, + }; + throw err; + } + + // Cookie is now set by the server (HttpOnly — not read here); record the flag. + this.markPaired(); + } + + /** + * Called by the HTTP invoker on a `401`: the session cookie is missing/expired. + * Clears the flag and notifies subscribers so the app returns to pairing. + */ + notifyUnauthorized(): void { + this.forget(); + for (const handler of this.unauthorizedHandlers) handler(); + } + + /** Subscribes to unauthorized transitions (returns an unsubscribe). */ + onUnauthorized(handler: () => void): Unsubscribe { + this.unauthorizedHandlers.add(handler); + return () => this.unauthorizedHandlers.delete(handler); + } +} + +// --------------------------------------------------------------------------- +// Module singleton (shared between the invoker and the pairing UI) +// --------------------------------------------------------------------------- + +let singleton: WebSession | null = null; + +/** Returns the shared web session, creating it on first use. */ +export function getWebSession(): WebSession { + if (!singleton) singleton = new WebSession(); + return singleton; +} + +/** Overrides the singleton (tests). Pass `null` to reset to a fresh default. */ +export function setWebSessionForTests(session: WebSession | null): void { + singleton = session; +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index c6819d7..3cb59f7 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -5,7 +5,8 @@ import ReactDOM from "react-dom/client"; import { App } from "./App"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; -import { DIProvider } from "./di"; +import { WebApp } from "@/features/web"; +import { DIProvider, resolveTransport } from "./di"; import "@/shared/styles/theme.css"; const root = document.getElementById("root"); @@ -18,10 +19,17 @@ if (!root) { // follows the main window's focused project; otherwise the full app. const viewParams = parseViewWindowParams(window.location.search); +// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the +// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders +// the full `App` (or a detached view window). Detached windows are desktop-only. +const isWeb = resolveTransport() === "http"; + ReactDOM.createRoot(root).render( - {viewParams ? ( + {isWeb ? ( + + ) : viewParams ? ( ) : ( diff --git a/frontend/src/features/web/PairingScreen.tsx b/frontend/src/features/web/PairingScreen.tsx new file mode 100644 index 0000000..04e0685 --- /dev/null +++ b/frontend/src/features/web/PairingScreen.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent): Promise { + 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 ( +
+ +
+
+

Appairer cet appareil

+

+ Saisissez le code affiché par le serveur IdeA pour connecter ce + navigateur. +

+
+ + + {({ id, describedBy }) => ( + setCode(e.target.value)} + placeholder="p. ex. 4821-93" + autoFocus + autoComplete="one-time-code" + disabled={busy} + invalid={!!error} + /> + )} + + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx new file mode 100644 index 0000000..7ae53ef --- /dev/null +++ b/frontend/src/features/web/WebApp.test.tsx @@ -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(); + 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 { + 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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx new file mode 100644 index 0000000..274f06d --- /dev/null +++ b/frontend/src/features/web/WebApp.tsx @@ -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 ( +
+
+
+

IdeA

+ + web + +
+ {paired && ( + + )} +
+ +
+ {paired ? ( + + ) : ( + setPaired(true)} /> + )} +
+
+ ); +} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx new file mode 100644 index 0000000..c376f1e --- /dev/null +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -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(null); + const [error, setError] = useState(null); + const [openId, setOpenId] = useState(null); + const [snapshot, setSnapshot] = useState(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 ( +
+
+

Projets

+ +
+ + {error && ( + +

{error}

+
+ )} + + {projects === null ? ( + + Chargement des projets… + + ) : projects.length === 0 ? ( +

Aucun projet.

+ ) : ( +
    + {projects.map((p) => ( +
  • + +
  • + ))} +
+ )} + + {openId && ( + +
+

+ État (lecture seule) + + read-only + +

+ {loadingSnapshot && } +
+ {snapshot ? ( + + ) : loadingSnapshot ? ( +

Chargement de l'état…

+ ) : ( +

Aucun état disponible.

+ )} +
+ )} +
+ ); +} + +/** Pure render of the read-only work-state snapshot. */ +function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) { + if (snapshot.agents.length === 0) { + return

Aucun agent actif.

; + } + return ( +
    + {snapshot.agents.map((a) => ( +
  • + {a.name} + + {a.live ? `live · ${a.live.kind}` : "offline"} + + {a.busy.state === "busy" ? "busy" : "idle"} + + +
  • + ))} +
+ ); +} diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts new file mode 100644 index 0000000..c15e2c5 --- /dev/null +++ b/frontend/src/features/web/index.ts @@ -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";