feat(frontend): surface agent web sur WebSocket (cellule agent) (#13)

Lot F4 du chantier server/client mode : le client web expose une cellule
agent branchée sur le PTY WebSocket, en face de agent.launch (B6).

- wsLiveClient.ts : launchAgent sur le transport WebSocket.
- streamGateways.ts : gateway agent alignée sur le contrat serveur B6.
- WebAgentCell.tsx : cellule agent web, câblée dans WebWorkspace et index.
- Tests : agentGateway.test.ts, WebApp.test.tsx.

Validé : frontend 749 tests verts, contrat B6↔F4 aligné (aucun écart de
frame), desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:17:24 +02:00
parent 6391d1c75a
commit 5254f16095
7 changed files with 359 additions and 32 deletions

View File

@ -17,6 +17,7 @@ import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Panel, Spinner } from "@/shared";
import { WebAgentCell } from "./WebAgentCell";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
@ -32,6 +33,10 @@ export function WebWorkspace() {
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 refresh = useCallback(async () => {
setError(null);
@ -52,6 +57,7 @@ export function WebWorkspace() {
setLoadingSnapshot(true);
setOpenId(projectId);
setSnapshot(null);
setOpenAgentId(null);
try {
// Read-only: open resolves the project server-side, then we read the
// live/work-state snapshot. No layout/agents/PTY are mounted.
@ -117,7 +123,11 @@ export function WebWorkspace() {
{loadingSnapshot && <Spinner size={12} />}
</div>
{snapshot ? (
<WorkStateSnapshot snapshot={snapshot} />
<WorkStateSnapshot
snapshot={snapshot}
openAgentId={openAgentId}
onOpenAgent={setOpenAgentId}
/>
) : loadingSnapshot ? (
<p className="text-xs text-muted">Chargement de l'état</p>
) : (
@ -125,19 +135,45 @@ export function WebWorkspace() {
)}
</Panel>
)}
{openId && openRoot && openAgentId && (
<Panel className="mt-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Agent</h3>
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
Fermer
</Button>
</div>
{/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */}
<WebAgentCell
key={openAgentId}
projectId={openId}
agentId={openAgentId}
cwd={openRoot}
/>
</Panel>
)}
</div>
);
}
/** Pure render of the read-only work-state snapshot. */
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
/** 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 <p className="text-xs text-muted">Aucun agent actif.</p>;
}
return (
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
{snapshot.agents.map((a) => (
<li key={a.agentId} className="flex items-center justify-between text-xs">
<li key={a.agentId} className="flex items-center justify-between gap-2 text-xs">
<span className="text-content">{a.name}</span>
<span className="flex items-center gap-2 text-faint">
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
@ -148,6 +184,14 @@ function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
>
{a.busy.state === "busy" ? "busy" : "idle"}
</span>
<Button
variant="ghost"
size="sm"
aria-pressed={openAgentId === a.agentId}
onClick={() => onOpenAgent(a.agentId)}
>
Ouvrir
</Button>
</span>
</li>
))}