feat(frontend): polish web (logout, 401→pairing, reconnexion globale) (#13)
Flow logout : POST /api/logout puis retour à l'écran de pairing et déconnexion du live. 401 renvoie au pairing sans boucle. Bannière de reconnexion couvrant le cas live-only et le serveur indisponible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -79,14 +79,33 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): {
|
|||||||
*/
|
*/
|
||||||
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
||||||
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
||||||
// Route 401s back to the pairing screen via the shared web session (F2).
|
// Route 401s back to the pairing screen via the shared web session (F2/F6).
|
||||||
const session = getWebSession();
|
const session = getWebSession();
|
||||||
|
// `ws` is referenced by the invoker's 401 handler (declared below); the closure
|
||||||
|
// only runs after both are constructed, so the forward reference is safe.
|
||||||
|
let ws: WsLiveClient;
|
||||||
const http = new HttpInvoker({
|
const http = new HttpInvoker({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token: config.token,
|
token: config.token,
|
||||||
onUnauthorized: () => session.notifyUnauthorized(),
|
onUnauthorized: () => {
|
||||||
|
// F6: a 401 means the session cookie is gone/revoked. Route back to pairing
|
||||||
|
// AND tear down the live socket so it stops its (now hopeless) reconnect
|
||||||
|
// loop. Re-pairing brings the same client back up cleanly.
|
||||||
|
session.notifyUnauthorized();
|
||||||
|
ws?.disconnect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws = new WsLiveClient({
|
||||||
|
wsUrl,
|
||||||
|
token: config.token,
|
||||||
|
// F6: when a WS reconnect fails we cannot read its HTTP status, so probe with
|
||||||
|
// a cheap `health` call. A revoked session answers `401` → the invoker's
|
||||||
|
// `onUnauthorized` above bounces to pairing; a network error is swallowed and
|
||||||
|
// the WS backoff keeps retrying.
|
||||||
|
onReconnectFailed: () => {
|
||||||
|
void http.invoke("health", { request: null }).catch(() => {});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const ws = new WsLiveClient({ wsUrl, token: config.token });
|
|
||||||
// Share the live client with the web feature so live surfaces can re-sync on
|
// Share the live client with the web feature so live surfaces can re-sync on
|
||||||
// reconnect (F5). Inert on desktop (never called there).
|
// reconnect (F5). Inert on desktop (never called there).
|
||||||
setWebLiveClient(ws);
|
setWebLiveClient(ws);
|
||||||
@ -120,5 +139,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
export { HttpInvoker } from "./httpInvoker";
|
export { HttpInvoker } from "./httpInvoker";
|
||||||
export { WsLiveClient } from "./wsLiveClient";
|
export { WsLiveClient } from "./wsLiveClient";
|
||||||
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
|
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
|
||||||
export { getWebLiveClient, setWebLiveClient } from "./webLive";
|
export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive";
|
||||||
export type { ConnectionState } from "./wsLiveClient";
|
export type { ConnectionState } from "./wsLiveClient";
|
||||||
|
|||||||
@ -26,3 +26,12 @@ export function setWebLiveClient(client: WsLiveClient | null): void {
|
|||||||
export function getWebLiveClient(): WsLiveClient | null {
|
export function getWebLiveClient(): WsLiveClient | null {
|
||||||
return liveClient;
|
return liveClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-disconnects the shared live client (sign-out / auth bounce — F6): tears
|
||||||
|
* down the socket and stops the reconnect loop but keeps the client reusable, so
|
||||||
|
* re-pairing reconnects cleanly. Inert (no client registered) on desktop.
|
||||||
|
*/
|
||||||
|
export function disconnectWebLive(): void {
|
||||||
|
liveClient?.disconnect();
|
||||||
|
}
|
||||||
|
|||||||
@ -90,3 +90,32 @@ describe("WebSession unauthorized handling", () => {
|
|||||||
expect(session.isPaired()).toBe(false);
|
expect(session.isPaired()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WebSession logout", () => {
|
||||||
|
it("POSTs /api/logout same-origin and clears the flag on success", async () => {
|
||||||
|
const store = memStore();
|
||||||
|
const { fetchImpl, calls } = fetchReturning(200, { revoked: true });
|
||||||
|
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
|
||||||
|
session.markPaired();
|
||||||
|
|
||||||
|
await session.logout();
|
||||||
|
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
expect(calls[0].url).toBe("https://host/api/logout");
|
||||||
|
const init = calls[0].init as { method: string; credentials: string };
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(init.credentials).toBe("same-origin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clears the flag when the server is unreachable (best-effort)", async () => {
|
||||||
|
const store = memStore();
|
||||||
|
const fetchImpl: FetchLike = async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
};
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
|
||||||
|
session.markPaired();
|
||||||
|
|
||||||
|
await expect(session.logout()).resolves.toBeUndefined();
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -96,6 +96,26 @@ export class WebSession {
|
|||||||
this.store.removeItem(PAIRED_FLAG_KEY);
|
this.store.removeItem(PAIRED_FLAG_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full sign-out (F6): asks the server to revoke the session cookie
|
||||||
|
* (`POST /api/logout`, same-origin so the cookie rides along) and then clears
|
||||||
|
* the local flag. Best-effort — a network failure or an already-expired session
|
||||||
|
* still clears the flag locally, so the UI always returns to pairing without a
|
||||||
|
* dead end. Disconnecting the live WS is the caller's job (composition seam).
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.fetchImpl(`${this.baseUrl}/api/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Server unreachable / already gone: fall through to the local clear.
|
||||||
|
} finally {
|
||||||
|
this.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
|
* 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
|
* server sets the session cookie and this records the paired flag. A wrong code
|
||||||
|
|||||||
@ -78,6 +78,14 @@ export interface WsLiveClientConfig {
|
|||||||
setTimeoutImpl?: SetTimeoutLike;
|
setTimeoutImpl?: SetTimeoutLike;
|
||||||
/** Injected `clearTimeout`. */
|
/** Injected `clearTimeout`. */
|
||||||
clearTimeoutImpl?: ClearTimeoutLike;
|
clearTimeoutImpl?: ClearTimeoutLike;
|
||||||
|
/**
|
||||||
|
* Called after a reconnection attempt fails. The browser cannot read the HTTP
|
||||||
|
* status of a rejected WS upgrade, so an auth-revoked socket looks like any
|
||||||
|
* other outage from here. F6 wires this to a cheap HTTP probe (`health`): a
|
||||||
|
* `401` there routes back to pairing (via the invoker's `onUnauthorized`),
|
||||||
|
* while a network error just lets the backoff keep retrying.
|
||||||
|
*/
|
||||||
|
onReconnectFailed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A live terminal subscription tracked for output routing + reconnection replay. */
|
/** A live terminal subscription tracked for output routing + reconnection replay. */
|
||||||
@ -137,6 +145,7 @@ export class WsLiveClient {
|
|||||||
private readonly reconnectMaxMs: number;
|
private readonly reconnectMaxMs: number;
|
||||||
private readonly setTimeoutImpl: SetTimeoutLike;
|
private readonly setTimeoutImpl: SetTimeoutLike;
|
||||||
private readonly clearTimeoutImpl: ClearTimeoutLike;
|
private readonly clearTimeoutImpl: ClearTimeoutLike;
|
||||||
|
private readonly onReconnectFailed?: () => void;
|
||||||
|
|
||||||
private socket: WebSocketLike | null = null;
|
private socket: WebSocketLike | null = null;
|
||||||
private opening: Promise<void> | null = null;
|
private opening: Promise<void> | null = null;
|
||||||
@ -172,6 +181,7 @@ export class WsLiveClient {
|
|||||||
config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms));
|
config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms));
|
||||||
this.clearTimeoutImpl =
|
this.clearTimeoutImpl =
|
||||||
config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
||||||
|
this.onReconnectFailed = config.onReconnectFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -274,7 +284,7 @@ export class WsLiveClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */
|
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */
|
||||||
private needsReconnect(): boolean {
|
needsReconnect(): boolean {
|
||||||
return this.terminals.size > 0 || this.domainEventHandler !== null;
|
return this.terminals.size > 0 || this.domainEventHandler !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,7 +307,10 @@ export class WsLiveClient {
|
|||||||
await this.connect();
|
await this.connect();
|
||||||
await this.reattachAll();
|
await this.reattachAll();
|
||||||
} catch {
|
} catch {
|
||||||
// Still down: back off and retry (unless nothing needs the socket anymore).
|
// Still down: probe (an auth-revoked upgrade is indistinguishable from an
|
||||||
|
// outage here — the probe surfaces a 401 as a pairing bounce), then back
|
||||||
|
// off and retry (unless nothing needs the socket anymore).
|
||||||
|
this.onReconnectFailed?.();
|
||||||
if (this.needsReconnect()) this.scheduleReconnect();
|
if (this.needsReconnect()) this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -479,6 +492,37 @@ export class WsLiveClient {
|
|||||||
return bytesToBase64(bytes);
|
return bytesToBase64(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft teardown for a sign-out / auth bounce (F6): closes the socket, drops all
|
||||||
|
* live state and stops the reconnect loop, but — unlike {@link dispose} — leaves
|
||||||
|
* the client reusable. A later `ensureConnected` (after re-pairing) reconnects
|
||||||
|
* as a fresh `connecting`. The socket's own `onclose` is detached first so it
|
||||||
|
* cannot flip the state back to `reconnecting`.
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.reconnectHandle) {
|
||||||
|
this.clearTimeoutImpl(this.reconnectHandle);
|
||||||
|
this.reconnectHandle = null;
|
||||||
|
}
|
||||||
|
this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" });
|
||||||
|
this.outputSinks.clear();
|
||||||
|
this.terminals.clear();
|
||||||
|
this.domainEventHandler = null;
|
||||||
|
const socket = this.socket;
|
||||||
|
if (socket) {
|
||||||
|
socket.onopen = null;
|
||||||
|
socket.onmessage = null;
|
||||||
|
socket.onerror = null;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
this.socket = null;
|
||||||
|
this.opening = null;
|
||||||
|
this.everConnected = false;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.setConnState("closed");
|
||||||
|
}
|
||||||
|
|
||||||
/** Closes the socket and rejects everything pending. */
|
/** Closes the socket and rejects everything pending. */
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
|
|||||||
@ -190,4 +190,66 @@ describe("WsLiveClient connection state + reconnection", () => {
|
|||||||
expect(h.ws.getConnectionState()).toBe("closed");
|
expect(h.ws.getConnectionState()).toBe("closed");
|
||||||
expect(h.hasTimer()).toBe(false);
|
expect(h.hasTimer()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => {
|
||||||
|
const h = harness();
|
||||||
|
h.ws.setDomainEventHandler(() => {});
|
||||||
|
await h.ws.ensureConnected();
|
||||||
|
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
|
||||||
|
|
||||||
|
// Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and
|
||||||
|
// the detached socket's onclose cannot flip the state back to reconnecting.
|
||||||
|
h.ws.disconnect();
|
||||||
|
expect(h.ws.getConnectionState()).toBe("closed");
|
||||||
|
expect(h.hasTimer()).toBe(false);
|
||||||
|
expect(h.ws.needsReconnect()).toBe(false);
|
||||||
|
|
||||||
|
// Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing).
|
||||||
|
await h.ws.ensureConnected();
|
||||||
|
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
|
||||||
|
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => {
|
||||||
|
const failed = vi.fn();
|
||||||
|
let openNextSocket = true;
|
||||||
|
let timerFn: (() => void) | null = null;
|
||||||
|
const sockets: FakeSocket[] = [];
|
||||||
|
const ws = new WsLiveClient({
|
||||||
|
wsUrl: "wss://host",
|
||||||
|
reconnectBaseMs: 1,
|
||||||
|
onReconnectFailed: failed,
|
||||||
|
socketFactory: () => {
|
||||||
|
const s = new FakeSocket();
|
||||||
|
sockets.push(s);
|
||||||
|
// First socket connects; the reconnect socket errors (upgrade rejected).
|
||||||
|
if (openNextSocket) queueMicrotask(() => s.onopen?.());
|
||||||
|
else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected")));
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
setTimeoutImpl: (fn) => {
|
||||||
|
timerFn = fn;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimeoutImpl: () => {
|
||||||
|
timerFn = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.setDomainEventHandler(() => {});
|
||||||
|
await ws.ensureConnected();
|
||||||
|
await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected"));
|
||||||
|
|
||||||
|
// Drop the socket; the next connect attempt will fail the upgrade.
|
||||||
|
openNextSocket = false;
|
||||||
|
sockets[0].close();
|
||||||
|
expect(ws.getConnectionState()).toBe("reconnecting");
|
||||||
|
const fire = timerFn as (() => void) | null;
|
||||||
|
timerFn = null;
|
||||||
|
fire?.();
|
||||||
|
|
||||||
|
// The failed reconnect fires the probe hook and schedules another attempt.
|
||||||
|
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
|
||||||
|
expect(timerFn).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -31,6 +31,16 @@ const okFetch: FetchLike = async () => ({
|
|||||||
text: async () => "{}",
|
text: async () => "{}",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Records fetch calls so a test can assert the logout POST. */
|
||||||
|
function recordingFetch(): { fetchImpl: FetchLike; calls: string[] } {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const fetchImpl: FetchLike = async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}" };
|
||||||
|
};
|
||||||
|
return { fetchImpl, calls };
|
||||||
|
}
|
||||||
|
|
||||||
const rejectFetch: FetchLike = async () => ({
|
const rejectFetch: FetchLike = async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 401,
|
status: 401,
|
||||||
@ -120,6 +130,22 @@ describe("WebApp pairing routing", () => {
|
|||||||
expect(await screen.findByTestId("web-agent-cell")).toBeTruthy();
|
expect(await screen.findByTestId("web-agent-cell")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("signs out via POST /api/logout and returns to pairing", async () => {
|
||||||
|
const { fetchImpl, calls } = recordingFetch();
|
||||||
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() });
|
||||||
|
session.markPaired();
|
||||||
|
renderWebApp(session, await seededGateways());
|
||||||
|
|
||||||
|
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Se déconnecter" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
|
||||||
|
expect(calls).toContain("https://h/api/logout");
|
||||||
|
expect(session.isPaired()).toBe(false);
|
||||||
|
expect(screen.queryByText("Projets")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
session.markPaired();
|
session.markPaired();
|
||||||
|
|||||||
@ -5,14 +5,15 @@
|
|||||||
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
|
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
|
||||||
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
|
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
|
||||||
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
|
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
|
||||||
* cookie) drops back to pairing automatically. A best-effort "Se déconnecter"
|
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
|
||||||
* clears the local flag (server-side cookie revocation is B8).
|
* server session (`POST /api/logout`), tears down the live WS singleton, and
|
||||||
|
* returns to pairing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/shared";
|
import { Button } from "@/shared";
|
||||||
import { getWebSession, type WebSession } from "@/adapters/http";
|
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
|
||||||
import { PairingScreen } from "./PairingScreen";
|
import { PairingScreen } from "./PairingScreen";
|
||||||
import { WebWorkspace } from "./WebWorkspace";
|
import { WebWorkspace } from "./WebWorkspace";
|
||||||
|
|
||||||
@ -24,15 +25,26 @@ interface WebAppProps {
|
|||||||
export function WebApp({ session }: WebAppProps = {}) {
|
export function WebApp({ session }: WebAppProps = {}) {
|
||||||
const webSession = session ?? getWebSession();
|
const webSession = session ?? getWebSession();
|
||||||
const [paired, setPaired] = useState(() => webSession.isPaired());
|
const [paired, setPaired] = useState(() => webSession.isPaired());
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A 401 anywhere clears the flag and fires this: return to pairing.
|
// A 401 anywhere clears the flag and fires this: return to pairing. The live
|
||||||
|
// WS is torn down by the composition-root 401 handler (F6).
|
||||||
return webSession.onUnauthorized(() => setPaired(false));
|
return webSession.onUnauthorized(() => setPaired(false));
|
||||||
}, [webSession]);
|
}, [webSession]);
|
||||||
|
|
||||||
function signOut(): void {
|
async function signOut(): Promise<void> {
|
||||||
webSession.forget();
|
if (signingOut) return;
|
||||||
setPaired(false);
|
setSigningOut(true);
|
||||||
|
try {
|
||||||
|
// Revoke the server session (best-effort), then drop the live socket so it
|
||||||
|
// stops reconnecting, and return to pairing regardless of the outcome.
|
||||||
|
await webSession.logout();
|
||||||
|
} finally {
|
||||||
|
disconnectWebLive();
|
||||||
|
setSigningOut(false);
|
||||||
|
setPaired(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -45,7 +57,7 @@ export function WebApp({ session }: WebAppProps = {}) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{paired && (
|
{paired && (
|
||||||
<Button variant="ghost" size="sm" onClick={signOut}>
|
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
|
||||||
Se déconnecter
|
Se déconnecter
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
|||||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||||
import { WebAgentCell } from "./WebAgentCell";
|
import { WebAgentCell } from "./WebAgentCell";
|
||||||
import { useLiveReconnect } from "./useLiveReconnect";
|
import { useLiveReconnect } from "./useLiveReconnect";
|
||||||
|
import { useLiveConnectionState } from "./useLiveConnectionState";
|
||||||
|
|
||||||
function describe(e: unknown): string {
|
function describe(e: unknown): string {
|
||||||
if (e && typeof e === "object" && "message" in e) {
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
@ -75,6 +76,7 @@ export function WebWorkspace() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
<ReconnectBanner />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||||
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||||
@ -119,6 +121,28 @@ export function WebWorkspace() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
|
||||||
|
* notice into xterm, but live-only surfaces (no terminal open) had no visible
|
||||||
|
* signal. This surfaces the shared live socket's `reconnecting` state so the user
|
||||||
|
* always knows the view may be momentarily stale; `useLiveReconnect` re-syncs the
|
||||||
|
* read-model on recovery. Inert on desktop (hook returns `null`).
|
||||||
|
*/
|
||||||
|
function ReconnectBanner() {
|
||||||
|
const connection = useLiveConnectionState();
|
||||||
|
if (connection !== "reconnecting") return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
data-testid="web-reconnect-banner"
|
||||||
|
className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
|
||||||
|
>
|
||||||
|
<Spinner size={12} />
|
||||||
|
Connexion perdue — reconnexion en cours…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Live work-state + background + inbox for the opened project (F5). */
|
/** Live work-state + background + inbox for the opened project (F5). */
|
||||||
function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) {
|
function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) {
|
||||||
const vm = useProjectWorkState(projectId);
|
const vm = useProjectWorkState(projectId);
|
||||||
|
|||||||
29
frontend/src/features/web/useLiveConnectionState.ts
Normal file
29
frontend/src/features/web/useLiveConnectionState.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* `useLiveConnectionState` — ticket #13, lot F6. Web-only hook exposing the shared
|
||||||
|
* live WebSocket's current {@link ConnectionState} to the UI so a reconnection
|
||||||
|
* banner can be shown even when no terminal is open (the F3 xterm notice only
|
||||||
|
* covers terminal cells; live-only surfaces had no visible signal).
|
||||||
|
*
|
||||||
|
* Inert outside web transport (no client registered), so it is safe to mount
|
||||||
|
* unconditionally: it returns `null` on desktop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { getWebLiveClient, type ConnectionState } from "@/adapters/http";
|
||||||
|
|
||||||
|
export function useLiveConnectionState(): ConnectionState | null {
|
||||||
|
const [state, setState] = useState<ConnectionState | null>(
|
||||||
|
() => getWebLiveClient()?.getConnectionState() ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const client = getWebLiveClient();
|
||||||
|
if (!client) return;
|
||||||
|
// Sync in case the state changed between the initial render and this effect.
|
||||||
|
setState(client.getConnectionState());
|
||||||
|
return client.onConnectionStateChange(setState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user