feat(frontend): surfaces live web (workstate live, background, inbox, reconnect) (#13)

Lot F5 du chantier server/client mode : le client web consomme les frames
event.domain (B7) pour animer ses surfaces live.

- webLive.ts : consommation du flux event.domain (workstate, background,
  inbox).
- useLiveReconnect.ts : reconnexion du flux live.
- wsLiveClient.ts / index.ts : câblage du transport live.
- WebWorkspace.tsx : surfaces live branchées.
- Tests : WebWorkspaceLive.test.tsx, wsLiveClientReconnect.test.ts,
  WebApp.test.tsx.

Validé : frontend 753 tests verts, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:37:02 +02:00
parent 1bc5217dbc
commit dd1d083a1a
8 changed files with 457 additions and 112 deletions

View File

@ -19,6 +19,7 @@ import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { getWebSession } from "./webSession";
import { setWebLiveClient } from "./webLive";
import {
HttpConversationGateway,
HttpEmbedderGateway,
@ -86,6 +87,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
onUnauthorized: () => session.notifyUnauthorized(),
});
const ws = new WsLiveClient({ wsUrl, token: config.token });
// Share the live client with the web feature so live surfaces can re-sync on
// reconnect (F5). Inert on desktop (never called there).
setWebLiveClient(ws);
return {
system: new HttpSystemGateway(http, ws),
@ -116,3 +120,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
export { HttpInvoker } from "./httpInvoker";
export { WsLiveClient } from "./wsLiveClient";
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
export { getWebLiveClient, setWebLiveClient } from "./webLive";
export type { ConnectionState } from "./wsLiveClient";

View File

@ -0,0 +1,28 @@
/**
* Web live-connection accessor — ticket #13, lot F5.
*
* The web gateways share a single {@link WsLiveClient} (created in
* `createHttpWsGateways`). The live surfaces (workstate / background /
* notifications) need to know when that socket **reconnects** after an outage so
* they can re-synchronise their read-models (events emitted while disconnected
* were missed). The `SystemGateway` port intentionally does not expose transport
* connection state, so — rather than leak it through the port — the web client is
* registered here as a module singleton and consumed only by the web feature
* (`useLiveReconnect`). Desktop never registers a client, so this is inert there.
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
*/
import type { WsLiveClient } from "./wsLiveClient";
let liveClient: WsLiveClient | null = null;
/** Registers the shared web live client (called by `createHttpWsGateways`). */
export function setWebLiveClient(client: WsLiveClient | null): void {
liveClient = client;
}
/** Returns the shared web live client, or `null` outside web transport. */
export function getWebLiveClient(): WsLiveClient | null {
return liveClient;
}

View File

@ -263,15 +263,19 @@ export class WsLiveClient {
this.setConnState("closed");
return;
}
// Only reconnect when there is a live terminal to re-attach; otherwise stay
// idle until the next explicit use.
this.setConnState("reconnecting");
// Reconnect while anything still needs the socket: a live terminal to
// re-attach (F3) OR an active domain-event subscription (F5 live surfaces).
// Otherwise stay idle until the next explicit use.
if (this.terminals.size > 0) {
this.setConnState("reconnecting");
for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…"));
this.scheduleReconnect();
} else {
this.setConnState("reconnecting");
}
if (this.needsReconnect()) this.scheduleReconnect();
}
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */
private needsReconnect(): boolean {
return this.terminals.size > 0 || this.domainEventHandler !== null;
}
private scheduleReconnect(): void {
@ -293,8 +297,8 @@ export class WsLiveClient {
await this.connect();
await this.reattachAll();
} catch {
// Still down: back off and retry (unless everything was detached/closed).
if (this.terminals.size > 0) this.scheduleReconnect();
// Still down: back off and retry (unless nothing needs the socket anymore).
if (this.needsReconnect()) this.scheduleReconnect();
}
}

View File

@ -158,6 +158,29 @@ describe("WsLiveClient connection state + reconnection", () => {
expect(h.hasTimer()).toBe(false);
});
it("reconnects for a live domain-event subscription even with no terminal (F5)", async () => {
const h = harness();
// A live surface subscribes to domain events (no terminal open).
h.ws.setDomainEventHandler(() => {});
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Drop the socket → must schedule a reconnect (the subscription still needs it).
h.sockets[0].close();
expect(h.ws.getConnectionState()).toBe("reconnecting");
expect(h.hasTimer()).toBe(true);
h.fireTimer();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Events resume flowing to the persisted handler after reconnect.
const events: unknown[] = [];
h.ws.setDomainEventHandler((e) => events.push(e));
h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
expect(events).toHaveLength(1);
});
it("dispose() moves to closed and clears any pending reconnect", async () => {
const h = harness();
await attach(h.ws, h.sockets, "s1", () => {});