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:
@ -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";
|
||||
|
||||
28
frontend/src/adapters/http/webLive.ts
Normal file
28
frontend/src/adapters/http/webLive.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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", () => {});
|
||||
|
||||
Reference in New Issue
Block a user