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:
76
frontend/src/features/web/WebAgentCell.tsx
Normal file
76
frontend/src/features/web/WebAgentCell.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Web agent cell — ticket #13, lot F4. Thin wiring (no new terminal logic) that
|
||||
* reuses the existing, transport-neutral {@link TerminalView} to render a CLI
|
||||
* agent over the WebSocket in web mode.
|
||||
*
|
||||
* The agent's CLI runs server-side (B6); the browser is display-only. This
|
||||
* component just supplies `TerminalView` with the DI **agent gateway** as the
|
||||
* opener/reattacher:
|
||||
* - `open` → `agent.launchAgent(projectId, agentId, …)` (frame `agent.launch`,
|
||||
* unified `terminal.attached` ack; the minted conversation id is carried on the
|
||||
* returned handle),
|
||||
* - `reattach` → `agent.reattach(sessionId, …)` (frame `terminal.attach`, no
|
||||
* relaunch, bounded scrollback repainted) — so a browser reload/reconnect
|
||||
* resumes the surviving server-side PTY.
|
||||
*
|
||||
* The session id is persisted in component state so a re-mount re-attaches rather
|
||||
* than relaunching, matching the desktop cell's lifecycle. A structured agent is
|
||||
* refused server-side (`UNSUPPORTED`) and surfaced by `TerminalView`'s own error
|
||||
* banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`).
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { TerminalView } from "@/features/terminals";
|
||||
|
||||
interface WebAgentCellProps {
|
||||
/** Owning project id (resolved server-side by `agent.launch`). */
|
||||
projectId: string;
|
||||
/** Agent to launch/attach. */
|
||||
agentId: string;
|
||||
/** Working directory for the cell (typically the project root). */
|
||||
cwd: string;
|
||||
/** Layout leaf id, when the caller tracks one (drives the singleton guard). */
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) {
|
||||
const { agent } = useGateways();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
const open = useCallback(
|
||||
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
|
||||
agent.launchAgent(
|
||||
projectId,
|
||||
agentId,
|
||||
nodeId ? { ...options, nodeId } : options,
|
||||
onData,
|
||||
),
|
||||
[agent, projectId, agentId, nodeId],
|
||||
);
|
||||
|
||||
const reattach = useCallback(
|
||||
(sid: string, onData: (bytes: Uint8Array) => void): Promise<ReattachResult> =>
|
||||
agent.reattach(sid, onData),
|
||||
[agent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="web-agent-cell" className="h-64 w-full">
|
||||
<TerminalView
|
||||
cwd={cwd}
|
||||
agentMode
|
||||
open={open}
|
||||
reattach={reattach}
|
||||
sessionId={sessionId}
|
||||
onSessionId={setSessionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -105,6 +105,21 @@ describe("WebApp pairing routing", () => {
|
||||
expect(snapshot.textContent).toContain("idle");
|
||||
});
|
||||
|
||||
it("opens a live agent cell for a work-state agent (F4 affordance)", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
await screen.findByTestId("web-workstate");
|
||||
|
||||
// The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell,
|
||||
// wired to the DI agent gateway (the CLI runs server-side).
|
||||
expect(screen.queryByTestId("web-agent-cell")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
|
||||
expect(await screen.findByTestId("web-agent-cell")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -6,3 +6,4 @@
|
||||
export { WebApp } from "./WebApp";
|
||||
export { PairingScreen } from "./PairingScreen";
|
||||
export { WebWorkspace } from "./WebWorkspace";
|
||||
export { WebAgentCell } from "./WebAgentCell";
|
||||
|
||||
Reference in New Issue
Block a user