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>
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
/**
|
|
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
|
|
*
|
|
* 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`.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
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) {
|
|
return String((e as GatewayError).message);
|
|
}
|
|
return String(e);
|
|
}
|
|
|
|
export function WebWorkspace() {
|
|
const { project, workState } = useGateways();
|
|
const [projects, setProjects] = useState<Project[] | null>(null);
|
|
const [error, setError] = 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 refresh = useCallback(async () => {
|
|
setError(null);
|
|
try {
|
|
setProjects(await project.listProjects());
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
}
|
|
}, [project]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
const openReadOnly = useCallback(
|
|
async (projectId: string) => {
|
|
setError(null);
|
|
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.
|
|
await project.openProject(projectId);
|
|
setSnapshot(await workState.getProjectWorkState(projectId));
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
} finally {
|
|
setLoadingSnapshot(false);
|
|
}
|
|
},
|
|
[project, workState],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
|
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
|
Rafraîchir
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<Panel className="border-danger/40">
|
|
<p className="text-sm text-danger">{error}</p>
|
|
</Panel>
|
|
)}
|
|
|
|
{projects === null ? (
|
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
|
<Spinner size={12} /> Chargement des projets…
|
|
</span>
|
|
) : projects.length === 0 ? (
|
|
<p className="text-sm text-muted">Aucun projet.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-2" data-testid="web-project-list">
|
|
{projects.map((p) => (
|
|
<li key={p.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => void openReadOnly(p.id)}
|
|
aria-pressed={openId === p.id}
|
|
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
|
>
|
|
<span className="text-sm font-medium text-content">{p.name}</span>
|
|
<span className="text-xs text-faint">{p.root}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{openId && (
|
|
<Panel className="mt-2">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">
|
|
État (lecture seule)
|
|
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
|
read-only
|
|
</span>
|
|
</h3>
|
|
{loadingSnapshot && <Spinner size={12} />}
|
|
</div>
|
|
{snapshot ? (
|
|
<WorkStateSnapshot
|
|
snapshot={snapshot}
|
|
openAgentId={openAgentId}
|
|
onOpenAgent={setOpenAgentId}
|
|
/>
|
|
) : loadingSnapshot ? (
|
|
<p className="text-xs text-muted">Chargement de l'état…</p>
|
|
) : (
|
|
<p className="text-xs text-muted">Aucun état disponible.</p>
|
|
)}
|
|
</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 + 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 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>
|
|
<span
|
|
className={
|
|
a.busy.state === "busy" ? "text-warning" : "text-success"
|
|
}
|
|
>
|
|
{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>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|