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 { HttpInvoker } from "./httpInvoker";
|
||||||
import { WsLiveClient } from "./wsLiveClient";
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
import { getWebSession } from "./webSession";
|
import { getWebSession } from "./webSession";
|
||||||
|
import { setWebLiveClient } from "./webLive";
|
||||||
import {
|
import {
|
||||||
HttpConversationGateway,
|
HttpConversationGateway,
|
||||||
HttpEmbedderGateway,
|
HttpEmbedderGateway,
|
||||||
@ -86,6 +87,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
onUnauthorized: () => session.notifyUnauthorized(),
|
onUnauthorized: () => session.notifyUnauthorized(),
|
||||||
});
|
});
|
||||||
const ws = new WsLiveClient({ wsUrl, token: config.token });
|
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 {
|
return {
|
||||||
system: new HttpSystemGateway(http, ws),
|
system: new HttpSystemGateway(http, ws),
|
||||||
@ -116,3 +120,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 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");
|
this.setConnState("closed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Only reconnect when there is a live terminal to re-attach; otherwise stay
|
this.setConnState("reconnecting");
|
||||||
// idle until the next explicit use.
|
// 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) {
|
if (this.terminals.size > 0) {
|
||||||
this.setConnState("reconnecting");
|
|
||||||
for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…"));
|
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 {
|
private scheduleReconnect(): void {
|
||||||
@ -293,8 +297,8 @@ export class WsLiveClient {
|
|||||||
await this.connect();
|
await this.connect();
|
||||||
await this.reattachAll();
|
await this.reattachAll();
|
||||||
} catch {
|
} catch {
|
||||||
// Still down: back off and retry (unless everything was detached/closed).
|
// Still down: back off and retry (unless nothing needs the socket anymore).
|
||||||
if (this.terminals.size > 0) this.scheduleReconnect();
|
if (this.needsReconnect()) this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -158,6 +158,29 @@ describe("WsLiveClient connection state + reconnection", () => {
|
|||||||
expect(h.hasTimer()).toBe(false);
|
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 () => {
|
it("dispose() moves to closed and clears any pending reconnect", async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
await attach(h.ws, h.sockets, "s1", () => {});
|
await attach(h.ws, h.sockets, "s1", () => {});
|
||||||
|
|||||||
@ -102,7 +102,7 @@ describe("WebApp pairing routing", () => {
|
|||||||
|
|
||||||
const snapshot = await screen.findByTestId("web-workstate");
|
const snapshot = await screen.findByTestId("web-workstate");
|
||||||
expect(snapshot.textContent).toContain("Archi");
|
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 () => {
|
it("opens a live agent cell for a work-state agent (F4 affordance)", async () => {
|
||||||
|
|||||||
@ -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**,
|
* After pairing: list projects, open one read-only, and show its **live**
|
||||||
* and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no
|
* work-state — agents (live/idle/busy), background tasks (with cancel/retry) and
|
||||||
* mutation — every call goes through the existing transport-neutral gateways
|
* per-agent inbox — updated in real time. The live mechanism is the desktop's own
|
||||||
* (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`.
|
* 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
|
* Read-only allowlist used (B4/B7): `list_projects`, `open_project`,
|
||||||
* commands B4 puts on the read-only allowlist: `list_projects`, `open_project`,
|
* `get_project_work_state`, plus the background `cancel`/`retry` commands and the
|
||||||
* `get_project_work_state`. A deliberately small surface — the full IDE (layout,
|
* `event.domain` live stream.
|
||||||
* agents, terminals) is out of scope until the streaming lots.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { 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 { WebAgentCell } from "./WebAgentCell";
|
||||||
|
import { useLiveReconnect } from "./useLiveReconnect";
|
||||||
|
|
||||||
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) {
|
||||||
@ -27,14 +37,10 @@ function describe(e: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebWorkspace() {
|
export function WebWorkspace() {
|
||||||
const { project, workState } = useGateways();
|
const { project } = useGateways();
|
||||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(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<string | null>(null);
|
|
||||||
|
|
||||||
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
||||||
|
|
||||||
@ -54,22 +60,17 @@ export function WebWorkspace() {
|
|||||||
const openReadOnly = useCallback(
|
const openReadOnly = useCallback(
|
||||||
async (projectId: string) => {
|
async (projectId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoadingSnapshot(true);
|
setOpenId(null);
|
||||||
setOpenId(projectId);
|
|
||||||
setSnapshot(null);
|
|
||||||
setOpenAgentId(null);
|
|
||||||
try {
|
try {
|
||||||
// Read-only: open resolves the project server-side, then we read the
|
// Read-only: resolve the project server-side; the live panel then streams
|
||||||
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
// its work-state. No layout/PTY is mounted here.
|
||||||
await project.openProject(projectId);
|
await project.openProject(projectId);
|
||||||
setSnapshot(await workState.getProjectWorkState(projectId));
|
setOpenId(projectId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
} finally {
|
|
||||||
setLoadingSnapshot(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[project, workState],
|
[project],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -112,89 +113,205 @@ export function WebWorkspace() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{openId && (
|
{openId && (
|
||||||
|
<LiveProjectPanel projectId={openId} root={openRoot} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string | null>(null);
|
||||||
|
const agents = vm.state?.agents ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
<Panel className="mt-2">
|
<Panel className="mt-2">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
État (lecture seule)
|
État live
|
||||||
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
||||||
read-only
|
read-only
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
{loadingSnapshot && <Spinner size={12} />}
|
<Button variant="ghost" size="sm" loading={vm.busy} onClick={() => void vm.refresh()}>
|
||||||
|
Rafraîchir
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{snapshot ? (
|
|
||||||
<WorkStateSnapshot
|
{vm.error && <p role="alert" className="mb-2 text-xs text-danger">{vm.error}</p>}
|
||||||
snapshot={snapshot}
|
|
||||||
openAgentId={openAgentId}
|
{vm.busy && vm.state === null ? (
|
||||||
onOpenAgent={setOpenAgentId}
|
|
||||||
/>
|
|
||||||
) : loadingSnapshot ? (
|
|
||||||
<p className="text-xs text-muted">Chargement de l'état…</p>
|
<p className="text-xs text-muted">Chargement de l'état…</p>
|
||||||
|
) : agents.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">Aucun agent actif.</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted">Aucun état disponible.</p>
|
<ul className="flex flex-col divide-y divide-border" data-testid="web-workstate">
|
||||||
)}
|
{agents.map((a) => (
|
||||||
</Panel>
|
<AgentLiveRow
|
||||||
|
key={a.agentId}
|
||||||
|
agent={a}
|
||||||
|
open={openAgentId === a.agentId}
|
||||||
|
onToggleOpen={() =>
|
||||||
|
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
|
||||||
|
}
|
||||||
|
onRefresh={vm.refresh}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openId && openRoot && openAgentId && (
|
{root && openAgentId && (
|
||||||
<Panel className="mt-2">
|
<div className="mt-3">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Agent</h3>
|
<span className="text-xs font-semibold text-muted">Agent</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
|
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
|
||||||
Fermer
|
Fermer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */}
|
<WebAgentCell key={openAgentId} projectId={projectId} agentId={openAgentId} cwd={root} />
|
||||||
<WebAgentCell
|
|
||||||
key={openAgentId}
|
|
||||||
projectId={openId}
|
|
||||||
agentId={openAgentId}
|
|
||||||
cwd={openRoot}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */
|
/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */
|
||||||
function WorkStateSnapshot({
|
function AgentLiveRow({
|
||||||
snapshot,
|
agent,
|
||||||
openAgentId,
|
open,
|
||||||
onOpenAgent,
|
onToggleOpen,
|
||||||
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
snapshot: ProjectWorkState;
|
agent: AgentWorkState;
|
||||||
openAgentId: string | null;
|
open: boolean;
|
||||||
onOpenAgent: (agentId: string) => void;
|
onToggleOpen: () => void;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
if (snapshot.agents.length === 0) {
|
const live = agent.live !== undefined;
|
||||||
return <p className="text-xs text-muted">Aucun agent actif.</p>;
|
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 (
|
return (
|
||||||
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
|
<li className="py-2 first:pt-0 last:pb-0">
|
||||||
{snapshot.agents.map((a) => (
|
<div className="flex items-center justify-between gap-2">
|
||||||
<li key={a.agentId} className="flex items-center justify-between gap-2 text-xs">
|
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
|
||||||
<span className="text-content">{a.name}</span>
|
<span className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||||
<span className="flex items-center gap-2 text-faint">
|
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
|
||||||
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
|
{live ? "Live" : "Offline"}
|
||||||
<span
|
|
||||||
className={
|
|
||||||
a.busy.state === "busy" ? "text-warning" : "text-success"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{a.busy.state === "busy" ? "busy" : "idle"}
|
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<span className={cn("rounded-full px-2 py-0.5 font-medium", busy ? "bg-warning/15 text-warning" : "bg-raised text-muted")}>
|
||||||
variant="ghost"
|
{busy ? "Busy" : "Idle"}
|
||||||
size="sm"
|
</span>
|
||||||
aria-pressed={openAgentId === a.agentId}
|
<Button variant="ghost" size="sm" aria-pressed={open} onClick={onToggleOpen}>
|
||||||
onClick={() => onOpenAgent(a.agentId)}
|
{open ? "Fermer" : "Ouvrir"}
|
||||||
>
|
|
||||||
Ouvrir
|
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inbox.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Inbox</p>
|
||||||
|
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
|
||||||
|
{inbox.map((item) => (
|
||||||
|
<li key={item.id} className="flex min-w-0 items-start gap-2 text-xs text-muted">
|
||||||
|
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium">{item.kind}</span>
|
||||||
|
<span className="min-w-0 flex-1 break-words">{item.body}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backgroundTasks.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Background tasks</p>
|
||||||
|
<ul aria-label={`${agent.name} background tasks`} className="mt-1 flex flex-col gap-1">
|
||||||
|
{backgroundTasks.map((task) => (
|
||||||
|
<WebBackgroundTaskRow key={task.taskId} task={task} onRefresh={onRefresh} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
|
||||||
|
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<void>;
|
||||||
|
}) {
|
||||||
|
const { workState } = useGateways();
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const canCancel = task.status === "running" || task.status === "pending";
|
||||||
|
const canRetry = task.status === "failed" || task.status === "cancelled";
|
||||||
|
|
||||||
|
async function runAction(action: (taskId: string) => Promise<void>): Promise<void> {
|
||||||
|
setActionBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await action(task.taskId);
|
||||||
|
await onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(describe(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="min-w-0 text-xs text-muted">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
|
||||||
|
{TASK_STATUS_LABEL[task.status]}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canCancel || actionBusy}
|
||||||
|
loading={actionBusy}
|
||||||
|
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canRetry || actionBusy}
|
||||||
|
loading={actionBusy}
|
||||||
|
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
140
frontend/src/features/web/WebWorkspaceLive.test.tsx
Normal file
140
frontend/src/features/web/WebWorkspaceLive.test.tsx
Normal file
@ -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<string, string>();
|
||||||
|
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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<WebApp session={session} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
27
frontend/src/features/web/useLiveReconnect.ts
Normal file
27
frontend/src/features/web/useLiveReconnect.ts
Normal file
@ -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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user