From dd1d083a1a071111d7eabf891ab1fd1f975f50f8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:37:02 +0200 Subject: [PATCH] feat(frontend): surfaces live web (workstate live, background, inbox, reconnect) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/adapters/http/index.ts | 6 + frontend/src/adapters/http/webLive.ts | 28 ++ frontend/src/adapters/http/wsLiveClient.ts | 20 +- .../http/wsLiveClientReconnect.test.ts | 23 ++ frontend/src/features/web/WebApp.test.tsx | 2 +- frontend/src/features/web/WebWorkspace.tsx | 323 ++++++++++++------ .../features/web/WebWorkspaceLive.test.tsx | 140 ++++++++ frontend/src/features/web/useLiveReconnect.ts | 27 ++ 8 files changed, 457 insertions(+), 112 deletions(-) create mode 100644 frontend/src/adapters/http/webLive.ts create mode 100644 frontend/src/features/web/WebWorkspaceLive.test.tsx create mode 100644 frontend/src/features/web/useLiveReconnect.ts diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 98c8070..57af01d 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -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"; diff --git a/frontend/src/adapters/http/webLive.ts b/frontend/src/adapters/http/webLive.ts new file mode 100644 index 0000000..0241beb --- /dev/null +++ b/frontend/src/adapters/http/webLive.ts @@ -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; +} diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 92d1d9c..96056d2 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -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(); } } diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts index da057e7..d0084bf 100644 --- a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -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", () => {}); diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index f4d3491..e85645c 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -102,7 +102,7 @@ describe("WebApp pairing routing", () => { const snapshot = await screen.findByTestId("web-workstate"); expect(snapshot.textContent).toContain("Archi"); - expect(snapshot.textContent).toContain("idle"); + expect(snapshot.textContent).toContain("Idle"); }); it("opens a live agent cell for a work-state agent (F4 affordance)", async () => { diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 7797cf1..af45ade 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -1,23 +1,33 @@ /** - * Read-only web workspace — ticket #13, lot F2 (first shippable increment). + * Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces). * - * 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`. + * After pairing: list projects, open one read-only, and show its **live** + * work-state — agents (live/idle/busy), background tasks (with cancel/retry) and + * per-agent inbox — updated in real time. The live mechanism is the desktop's own + * transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on + * relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect} + * re-synchronises after a WS outage. Background cancel/retry go through the + * {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can + * be opened into a live cell (F4). 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. + * Read-only allowlist used (B4/B7): `list_projects`, `open_project`, + * `get_project_work_state`, plus the background `cancel`/`retry` commands and the + * `event.domain` live stream. */ import { useCallback, useEffect, useState } from "react"; -import type { GatewayError, Project, ProjectWorkState } from "@/domain"; +import type { + AgentWorkState, + BackgroundCompletion, + GatewayError, + Project, +} from "@/domain"; import { useGateways } from "@/app/di"; -import { Button, Panel, Spinner } from "@/shared"; +import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; +import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; +import { useLiveReconnect } from "./useLiveReconnect"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -27,14 +37,10 @@ function describe(e: unknown): string { } export function WebWorkspace() { - const { project, workState } = useGateways(); + const { project } = 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); - // The agent currently opened in a live cell (CLI streamed over the WS), if any. - const [openAgentId, setOpenAgentId] = useState(null); const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; @@ -54,22 +60,17 @@ export function WebWorkspace() { const openReadOnly = useCallback( async (projectId: string) => { setError(null); - setLoadingSnapshot(true); - setOpenId(projectId); - setSnapshot(null); - setOpenAgentId(null); + setOpenId(null); try { - // Read-only: open resolves the project server-side, then we read the - // live/work-state snapshot. No layout/agents/PTY are mounted. + // Read-only: resolve the project server-side; the live panel then streams + // its work-state. No layout/PTY is mounted here. await project.openProject(projectId); - setSnapshot(await workState.getProjectWorkState(projectId)); + setOpenId(projectId); } catch (e) { setError(describe(e)); - } finally { - setLoadingSnapshot(false); } }, - [project, workState], + [project], ); return ( @@ -112,89 +113,205 @@ export function WebWorkspace() { )} {openId && ( - -
-

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

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

Chargement de l'état…

- ) : ( -

Aucun état disponible.

- )} -
- )} - - {openId && openRoot && openAgentId && ( - -
-

Agent

- -
- {/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */} - -
+ )} ); } -/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */ -function WorkStateSnapshot({ - snapshot, - openAgentId, - onOpenAgent, -}: { - snapshot: ProjectWorkState; - openAgentId: string | null; - onOpenAgent: (agentId: string) => void; -}) { - if (snapshot.agents.length === 0) { - return

Aucun agent actif.

; - } +/** Live work-state + background + inbox for the opened project (F5). */ +function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) { + const vm = useProjectWorkState(projectId); + // Re-sync the read-model when the WS reconnects (events missed while offline). + useLiveReconnect(vm.refresh); + const [openAgentId, setOpenAgentId] = useState(null); + const agents = vm.state?.agents ?? []; + return ( -
    - {snapshot.agents.map((a) => ( -
  • - {a.name} - - {a.live ? `live · ${a.live.kind}` : "offline"} - - {a.busy.state === "busy" ? "busy" : "idle"} - - + +
    +

    + État live + + read-only -

  • - ))} -
+ + + + + {vm.error &&

{vm.error}

} + + {vm.busy && vm.state === null ? ( +

Chargement de l'état…

+ ) : agents.length === 0 ? ( +

Aucun agent actif.

+ ) : ( +
    + {agents.map((a) => ( + + setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)) + } + onRefresh={vm.refresh} + /> + ))} +
+ )} + + {root && openAgentId && ( +
+
+ Agent + +
+ +
+ )} + + ); +} + +/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */ +function AgentLiveRow({ + agent, + open, + onToggleOpen, + onRefresh, +}: { + agent: AgentWorkState; + open: boolean; + onToggleOpen: () => void; + onRefresh: () => Promise; +}) { + const live = agent.live !== undefined; + const busy = agent.busy?.state === "busy"; + const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs); + const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort( + (a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId), + ); + + return ( +
  • +
    + {agent.name} + + + {live ? "Live" : "Offline"} + + + {busy ? "Busy" : "Idle"} + + + +
    + + {inbox.length > 0 && ( +
    +

    Inbox

    +
      + {inbox.map((item) => ( +
    • + {item.kind} + {item.body} +
    • + ))} +
    +
    + )} + + {backgroundTasks.length > 0 && ( +
    +

    Background tasks

    +
      + {backgroundTasks.map((task) => ( + + ))} +
    +
    + )} +
  • + ); +} + +const TASK_STATUS_LABEL: Record = { + running: "Running", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", + pending: "Pending", + delivered: "Delivered", +}; + +function taskStatusClass(status: BackgroundCompletion["status"]): string { + if (status === "running" || status === "pending") return "bg-warning/15 text-warning"; + if (status === "completed" || status === "delivered") return "bg-success/10 text-success"; + if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger"; + return "bg-raised text-muted"; +} + +/** One background task with live status + cancel/retry via the DI gateway. */ +function WebBackgroundTaskRow({ + task, + onRefresh, +}: { + task: BackgroundCompletion; + onRefresh: () => Promise; +}) { + const { workState } = useGateways(); + const [actionBusy, setActionBusy] = useState(false); + const [message, setMessage] = useState(null); + const canCancel = task.status === "running" || task.status === "pending"; + const canRetry = task.status === "failed" || task.status === "cancelled"; + + async function runAction(action: (taskId: string) => Promise): Promise { + setActionBusy(true); + setMessage(null); + try { + await action(task.taskId); + await onRefresh(); + } catch (e) { + setMessage(describe(e)); + } finally { + setActionBusy(false); + } + } + + return ( +
  • +
    + + {TASK_STATUS_LABEL[task.status]} + + {task.kind} + + +
    + {message &&

    {message}

    } +
  • ); } diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx new file mode 100644 index 0000000..fe40c52 --- /dev/null +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -0,0 +1,140 @@ +/** + * F5 — the live web surfaces: a `event.domain` event refreshes the displayed + * work-state; background tasks render with status + cancel/retry wired to the + * gateway; a WS reconnect re-synchronises the read-model. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { BackgroundCompletion, ProjectWorkState } from "@/domain"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; +import { + WsLiveClient, + setWebLiveClient, + WebSession, +} from "@/adapters/http"; +import { WebApp } from "./WebApp"; + +afterEach(() => setWebLiveClient(null)); + +const okFetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +function memStore() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + }; +} + +function state(agents: ProjectWorkState["agents"]): ProjectWorkState { + return { agents, conversations: [] }; +} + +async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Demo", "/srv/demo"); + (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial); + return { gateways, projectId: project.id }; +} + +function renderPaired(gateways: Gateways) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + return render( + + + , + ); +} + +const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] }; + +describe("WebWorkspace live surfaces (F5)", () => { + it("refreshes the work-state on a relevant event.domain event", async () => { + const { gateways, projectId } = await seeded(state([AGENT_IDLE])); + renderPaired(gateways); + + fireEvent.click(await screen.findByText("Demo")); + const panel = await screen.findByTestId("web-workstate"); + expect(panel.textContent).toContain("Idle"); + + // The agent goes busy server-side; the read-model now reflects it… + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]), + ); + // …and an `agentBusyChanged` domain event drives a live refresh. + act(() => { + (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true }); + }); + + await waitFor(() => + expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"), + ); + }); + + it("renders background tasks and wires cancel through the gateway", async () => { + const task: BackgroundCompletion = { + taskId: "bt-1", + ownerAgentId: "a1", + projectId: "p", + kind: "shell", + status: "running", + exitCode: null, + summary: null, + stdoutTail: null, + stderrTail: null, + updatedAtMs: 1, + }; + const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }])); + const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask"); + renderPaired(gateways); + + fireEvent.click(await screen.findByText("Demo")); + const tasks = await screen.findByLabelText("Archi background tasks"); + expect(tasks.textContent).toContain("Running"); + expect(tasks.textContent).toContain("shell"); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1")); + }); + + it("re-synchronises the read-model when the WS reconnects", async () => { + const { gateways, projectId } = await seeded(state([AGENT_IDLE])); + // Register a live client whose connection state we can drive. + let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null; + const fakeClient = { + getConnectionState: () => "connected" as const, + onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => { + notify = l; + return () => { + notify = null; + }; + }, + }; + setWebLiveClient(fakeClient as unknown as WsLiveClient); + + const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState"); + renderPaired(gateways); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + const callsAfterOpen = refreshSpy.mock.calls.length; + expect(projectId).toBeTruthy(); + + // Simulate a reconnect: reconnecting → connected must re-fetch the read-model. + act(() => { + notify?.("reconnecting"); + notify?.("connected"); + }); + await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen)); + }); +}); diff --git a/frontend/src/features/web/useLiveReconnect.ts b/frontend/src/features/web/useLiveReconnect.ts new file mode 100644 index 0000000..aacd8c4 --- /dev/null +++ b/frontend/src/features/web/useLiveReconnect.ts @@ -0,0 +1,27 @@ +/** + * `useLiveReconnect` — ticket #13, lot F5. Web-only hook that re-synchronises a + * live read-model when the shared WebSocket **reconnects** after an outage. + * + * During a disconnection, domain events emitted by the server are missed, so a + * plain event-driven refresh (the desktop `useProjectWorkState` mechanism) would + * leave the UI stale until the next event. This hook watches the web live + * client's connection state and calls `onReconnect` on each `reconnecting → + * connected` transition. It is inert outside web transport (no client + * registered), so it is safe to mount unconditionally. + */ + +import { useEffect } from "react"; + +import { getWebLiveClient } from "@/adapters/http"; + +export function useLiveReconnect(onReconnect: () => void): void { + useEffect(() => { + const client = getWebLiveClient(); + if (!client) return; + let previous = client.getConnectionState(); + return client.onConnectionStateChange((state) => { + if (state === "connected" && previous === "reconnecting") onReconnect(); + previous = state; + }); + }, [onReconnect]); +}