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:
172
frontend/src/adapters/http/agentGateway.test.ts
Normal file
172
frontend/src/adapters/http/agentGateway.test.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* F4 — the WS agent gateway round-trip against the B6 frame contract:
|
||||||
|
* `agent.launch` → unified `terminal.attached` (sessionId + assignedConversationId
|
||||||
|
* consumed), then output/input/resize reuse the terminal mechanics; agent
|
||||||
|
* re-attach uses `terminal.attach` (no relaunch) and returns scrollback; a
|
||||||
|
* structured agent (`UNSUPPORTED`) and a dead session (`NOT_FOUND`) reject clearly.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import { HttpAgentGateway } from "./streamGateways";
|
||||||
|
import { HttpInvoker } from "./httpInvoker";
|
||||||
|
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
|
||||||
|
import { bytesToBase64 } from "./frames";
|
||||||
|
|
||||||
|
class FakeSocket implements WebSocketLike {
|
||||||
|
sent: string[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null;
|
||||||
|
onerror: ((event: unknown) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
send(data: string): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
receive(frame: unknown): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(frame) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
|
||||||
|
const sockets: FakeSocket[] = [];
|
||||||
|
const ws = new WsLiveClient({
|
||||||
|
wsUrl: "wss://host",
|
||||||
|
socketFactory: () => {
|
||||||
|
const s = new FakeSocket();
|
||||||
|
sockets.push(s);
|
||||||
|
queueMicrotask(() => s.onopen?.());
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The HTTP invoker is unused by the WS paths under test.
|
||||||
|
const gw = new HttpAgentGateway(new HttpInvoker({ baseUrl: "https://host" }), ws);
|
||||||
|
return { gw, sockets };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToLast(
|
||||||
|
socket: FakeSocket,
|
||||||
|
index: number,
|
||||||
|
reply: (id: string) => unknown,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
|
||||||
|
const sent = JSON.parse(socket.sent[index]);
|
||||||
|
socket.receive(reply(sent.id));
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachedAck(
|
||||||
|
id: string,
|
||||||
|
sessionId: string,
|
||||||
|
opts: { scroll?: Uint8Array; assignedConversationId?: string } = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "terminal.attached",
|
||||||
|
replyTo: id,
|
||||||
|
payload: {
|
||||||
|
session: { sessionId, rows: 24, cols: 80 },
|
||||||
|
scrollback: opts.scroll ? [{ seq: 0, bytesBase64: bytesToBase64(opts.scroll) }] : [],
|
||||||
|
nextSeq: opts.scroll ? 1 : 0,
|
||||||
|
status: "running",
|
||||||
|
gap: false,
|
||||||
|
assignedConversationId: opts.assignedConversationId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
|
||||||
|
|
||||||
|
describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||||
|
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
|
||||||
|
const { gw, sockets } = gateway();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
const handlePromise = gw.launchAgent("proj-1", "agent-1", { ...OPTS, conversationId: "resume-9" }, (b) =>
|
||||||
|
chunks.push(b),
|
||||||
|
);
|
||||||
|
const sent = await replyToLast(sockets[0], 0, (id) =>
|
||||||
|
attachedAck(id, "sess-1", { assignedConversationId: "conv-1" }),
|
||||||
|
);
|
||||||
|
const handle = await handlePromise;
|
||||||
|
|
||||||
|
// B6 payload: flat camelCase, no cwd (agent launch resolves the project).
|
||||||
|
expect(sent.kind).toBe("agent.launch");
|
||||||
|
expect(sent.payload).toEqual({
|
||||||
|
projectId: "proj-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
conversationId: "resume-9",
|
||||||
|
});
|
||||||
|
expect(handle.sessionId).toBe("sess-1");
|
||||||
|
// The conversation id minted by the launch is surfaced on the handle.
|
||||||
|
expect(handle.assignedConversationId).toBe("conv-1");
|
||||||
|
// Fresh launch: nothing repainted.
|
||||||
|
expect(chunks).toHaveLength(0);
|
||||||
|
|
||||||
|
// Output/input/resize reuse the terminal mechanics.
|
||||||
|
sockets[0].receive({
|
||||||
|
kind: "terminal.output",
|
||||||
|
payload: { sessionId: "sess-1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([65])) },
|
||||||
|
});
|
||||||
|
expect(chunks).toEqual([new Uint8Array([65])]);
|
||||||
|
|
||||||
|
const base = sockets[0].sent.length;
|
||||||
|
await handle.write(new Uint8Array([13]));
|
||||||
|
await handle.resize(30, 100);
|
||||||
|
expect(JSON.parse(sockets[0].sent[base]).kind).toBe("terminal.input");
|
||||||
|
expect(JSON.parse(sockets[0].sent[base + 1])).toMatchObject({
|
||||||
|
kind: "terminal.resize",
|
||||||
|
payload: { sessionId: "sess-1", rows: 30, cols: 100 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits assignedConversationId on the handle when the launch minted none", async () => {
|
||||||
|
const { gw, sockets } = gateway();
|
||||||
|
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
|
||||||
|
await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-x"));
|
||||||
|
const handle = await handlePromise;
|
||||||
|
expect(handle.assignedConversationId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-attaches to an existing agent via terminal.attach (no relaunch) and returns scrollback", async () => {
|
||||||
|
const { gw, sockets } = gateway();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const scroll = new Uint8Array([104, 105]);
|
||||||
|
|
||||||
|
const resPromise = gw.reattach("sess-7", (b) => chunks.push(b));
|
||||||
|
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-7", { scroll }));
|
||||||
|
const res = await resPromise;
|
||||||
|
|
||||||
|
// Re-attach must NOT relaunch: the frame is terminal.attach, not agent.launch.
|
||||||
|
expect(sent.kind).toBe("terminal.attach");
|
||||||
|
expect(sent.payload).toMatchObject({ sessionId: "sess-7" });
|
||||||
|
expect(res.scrollback).toEqual(scroll);
|
||||||
|
// Scrollback returned for the view to repaint, not double-pushed via onData.
|
||||||
|
expect(chunks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a structured agent with UNSUPPORTED (no crash)", async () => {
|
||||||
|
const { gw, sockets } = gateway();
|
||||||
|
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
|
||||||
|
await replyToLast(sockets[0], 0, (id) => ({
|
||||||
|
kind: "error",
|
||||||
|
replyTo: id,
|
||||||
|
payload: { code: "UNSUPPORTED", message: "structured agent sessions do not stream over the PTY websocket" },
|
||||||
|
}));
|
||||||
|
await expect(handlePromise).rejects.toMatchObject({ code: "UNSUPPORTED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects re-attach to a missing/exited session with NOT_FOUND", async () => {
|
||||||
|
const { gw, sockets } = gateway();
|
||||||
|
const resPromise = gw.reattach("gone", () => {});
|
||||||
|
await replyToLast(sockets[0], 0, (id) => ({
|
||||||
|
kind: "error",
|
||||||
|
replyTo: id,
|
||||||
|
payload: { code: "NOT_FOUND", message: "terminal session not found" },
|
||||||
|
}));
|
||||||
|
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -52,22 +52,9 @@ import type {
|
|||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import type { HttpInvoker } from "./httpInvoker";
|
import type { HttpInvoker } from "./httpInvoker";
|
||||||
import { WsLiveClient } from "./wsLiveClient";
|
import { WsLiveClient } from "./wsLiveClient";
|
||||||
import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
|
import { type ChatOutputPayload } from "./frames";
|
||||||
import { unsupportedOnWeb } from "./unsupported";
|
import { unsupportedOnWeb } from "./unsupported";
|
||||||
|
|
||||||
/** Concatenates a scrollback frame list into a single byte buffer. */
|
|
||||||
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
|
|
||||||
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
|
|
||||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
||||||
const out = new Uint8Array(total);
|
|
||||||
let offset = 0;
|
|
||||||
for (const c of chunks) {
|
|
||||||
out.set(c, offset);
|
|
||||||
offset += c.length;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
|
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
|
||||||
* output stream is delivered through the sink the gateway registered on the
|
* output stream is delivered through the sink the gateway registered on the
|
||||||
@ -235,34 +222,34 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<TerminalHandle> {
|
): Promise<TerminalHandle> {
|
||||||
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
|
// B6: a raw-CLI agent streams over the same PTY channel as a terminal. The
|
||||||
// sessions do not use this channel — that branch is B6.
|
// WS client tracks the session (reconnection/replay/status parity with F3);
|
||||||
const ack = await this.ws.send("agent.launch", {
|
// the unified `terminal.attached` ack yields the sessionId + the conversation
|
||||||
|
// id minted by this launch. Structured agents are refused server-side with
|
||||||
|
// `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error,
|
||||||
|
// no crash). A fresh launch has empty scrollback (no repaint on open).
|
||||||
|
const res = await this.ws.launchAgent({
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
nodeId: options.nodeId ?? null,
|
nodeId: options.nodeId ?? null,
|
||||||
rows: options.rows,
|
rows: options.rows,
|
||||||
cols: options.cols,
|
cols: options.cols,
|
||||||
conversationId: options.conversationId ?? null,
|
conversationId: options.conversationId ?? null,
|
||||||
|
onData,
|
||||||
});
|
});
|
||||||
const payload = ack.payload as unknown as AttachedPayload;
|
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
|
||||||
const sessionId = payload.session.sessionId;
|
|
||||||
this.ws.setOutputSink(sessionId, onData);
|
|
||||||
const scrollback = attachedToScrollback(payload);
|
|
||||||
if (scrollback.length > 0) onData(scrollback);
|
|
||||||
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reattach(
|
async reattach(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onData: (bytes: Uint8Array) => void,
|
onData: (bytes: Uint8Array) => void,
|
||||||
): Promise<ReattachResult> {
|
): Promise<ReattachResult> {
|
||||||
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
// Agent sessions re-attach through the same `terminal.attach` mechanics as a
|
||||||
const payload = ack.payload as unknown as AttachedPayload;
|
// terminal — no relaunch, bounded scrollback replayed for the view to repaint.
|
||||||
this.ws.setOutputSink(sessionId, onData);
|
const res = await this.ws.attachTerminal({ sessionId, onData });
|
||||||
return {
|
return {
|
||||||
handle: makeWsTerminalHandle(sessionId, this.ws),
|
handle: makeWsTerminalHandle(res.sessionId, this.ws),
|
||||||
scrollback: attachedToScrollback(payload),
|
scrollback: res.scrollback,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -340,6 +340,38 @@ export class WsLiveClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches a CLI agent (`agent.launch`) over the same PTY channel and tracks
|
||||||
|
* it exactly like a terminal (B6): the ack is a unified `terminal.attached`
|
||||||
|
* carrying `sessionId` + `assignedConversationId`, then output/input/resize and
|
||||||
|
* reconnection replay reuse the terminal mechanics. Structured agents are
|
||||||
|
* refused server-side with `UNSUPPORTED` (this channel is PTY-only); the
|
||||||
|
* rejected `send` surfaces that error to the caller unchanged.
|
||||||
|
*/
|
||||||
|
launchAgent(params: {
|
||||||
|
projectId: string;
|
||||||
|
agentId: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
nodeId?: string | null;
|
||||||
|
conversationId?: string | null;
|
||||||
|
onData: (bytes: Uint8Array) => void;
|
||||||
|
onStatus?: (status: string, exitCode?: number | null) => void;
|
||||||
|
}): Promise<TerminalAttachResult> {
|
||||||
|
return this.attachInternal(
|
||||||
|
"agent.launch",
|
||||||
|
{
|
||||||
|
projectId: params.projectId,
|
||||||
|
agentId: params.agentId,
|
||||||
|
nodeId: params.nodeId ?? null,
|
||||||
|
rows: params.rows,
|
||||||
|
cols: params.cols,
|
||||||
|
conversationId: params.conversationId ?? null,
|
||||||
|
},
|
||||||
|
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */
|
/** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */
|
||||||
attachTerminal(params: {
|
attachTerminal(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
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");
|
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 () => {
|
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||||
session.markPaired();
|
session.markPaired();
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
|
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Panel, Spinner } from "@/shared";
|
import { Button, Panel, Spinner } from "@/shared";
|
||||||
|
import { WebAgentCell } from "./WebAgentCell";
|
||||||
|
|
||||||
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) {
|
||||||
@ -32,6 +33,10 @@ export function WebWorkspace() {
|
|||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
|
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
|
||||||
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -52,6 +57,7 @@ export function WebWorkspace() {
|
|||||||
setLoadingSnapshot(true);
|
setLoadingSnapshot(true);
|
||||||
setOpenId(projectId);
|
setOpenId(projectId);
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
|
setOpenAgentId(null);
|
||||||
try {
|
try {
|
||||||
// Read-only: open resolves the project server-side, then we read the
|
// Read-only: open resolves the project server-side, then we read the
|
||||||
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
||||||
@ -117,7 +123,11 @@ export function WebWorkspace() {
|
|||||||
{loadingSnapshot && <Spinner size={12} />}
|
{loadingSnapshot && <Spinner size={12} />}
|
||||||
</div>
|
</div>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<WorkStateSnapshot snapshot={snapshot} />
|
<WorkStateSnapshot
|
||||||
|
snapshot={snapshot}
|
||||||
|
openAgentId={openAgentId}
|
||||||
|
onOpenAgent={setOpenAgentId}
|
||||||
|
/>
|
||||||
) : loadingSnapshot ? (
|
) : loadingSnapshot ? (
|
||||||
<p className="text-xs text-muted">Chargement de l'état…</p>
|
<p className="text-xs text-muted">Chargement de l'état…</p>
|
||||||
) : (
|
) : (
|
||||||
@ -125,19 +135,45 @@ export function WebWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure render of the read-only work-state snapshot. */
|
/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */
|
||||||
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
|
function WorkStateSnapshot({
|
||||||
|
snapshot,
|
||||||
|
openAgentId,
|
||||||
|
onOpenAgent,
|
||||||
|
}: {
|
||||||
|
snapshot: ProjectWorkState;
|
||||||
|
openAgentId: string | null;
|
||||||
|
onOpenAgent: (agentId: string) => void;
|
||||||
|
}) {
|
||||||
if (snapshot.agents.length === 0) {
|
if (snapshot.agents.length === 0) {
|
||||||
return <p className="text-xs text-muted">Aucun agent actif.</p>;
|
return <p className="text-xs text-muted">Aucun agent actif.</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
|
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
|
||||||
{snapshot.agents.map((a) => (
|
{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="text-content">{a.name}</span>
|
||||||
<span className="flex items-center gap-2 text-faint">
|
<span className="flex items-center gap-2 text-faint">
|
||||||
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
|
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
|
||||||
@ -148,6 +184,14 @@ function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
|
|||||||
>
|
>
|
||||||
{a.busy.state === "busy" ? "busy" : "idle"}
|
{a.busy.state === "busy" ? "busy" : "idle"}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={openAgentId === a.agentId}
|
||||||
|
onClick={() => onOpenAgent(a.agentId)}
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -6,3 +6,4 @@
|
|||||||
export { WebApp } from "./WebApp";
|
export { WebApp } from "./WebApp";
|
||||||
export { PairingScreen } from "./PairingScreen";
|
export { PairingScreen } from "./PairingScreen";
|
||||||
export { WebWorkspace } from "./WebWorkspace";
|
export { WebWorkspace } from "./WebWorkspace";
|
||||||
|
export { WebAgentCell } from "./WebAgentCell";
|
||||||
|
|||||||
Reference in New Issue
Block a user