Files
IdeA/frontend/src/features/web/WebWorkspace.tsx
Blomios dd1d083a1a 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>
2026-07-15 23:37:02 +02:00

318 lines
11 KiB
TypeScript

/**
* Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces).
*
* After pairing: list projects, open one read-only, and show its **live**
* work-state — agents (live/idle/busy), background tasks (with cancel/retry) and
* per-agent inbox — updated in real time. The live mechanism is the desktop's own
* 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`.
*
* Read-only allowlist used (B4/B7): `list_projects`, `open_project`,
* `get_project_work_state`, plus the background `cancel`/`retry` commands and the
* `event.domain` live stream.
*/
import { useCallback, useEffect, useState } from "react";
import type {
AgentWorkState,
BackgroundCompletion,
GatewayError,
Project,
} from "@/domain";
import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import { Button, Panel, Spinner, cn } from "@/shared";
import { WebAgentCell } from "./WebAgentCell";
import { useLiveReconnect } from "./useLiveReconnect";
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 } = useGateways();
const [projects, setProjects] = useState<Project[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = 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);
setOpenId(null);
try {
// Read-only: resolve the project server-side; the live panel then streams
// its work-state. No layout/PTY is mounted here.
await project.openProject(projectId);
setOpenId(projectId);
} catch (e) {
setError(describe(e));
}
},
[project],
);
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 && (
<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">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">
État live
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
read-only
</span>
</h3>
<Button variant="ghost" size="sm" loading={vm.busy} onClick={() => void vm.refresh()}>
Rafraîchir
</Button>
</div>
{vm.error && <p role="alert" className="mb-2 text-xs text-danger">{vm.error}</p>}
{vm.busy && vm.state === null ? (
<p className="text-xs text-muted">Chargement de l'état</p>
) : agents.length === 0 ? (
<p className="text-xs text-muted">Aucun agent actif.</p>
) : (
<ul className="flex flex-col divide-y divide-border" data-testid="web-workstate">
{agents.map((a) => (
<AgentLiveRow
key={a.agentId}
agent={a}
open={openAgentId === a.agentId}
onToggleOpen={() =>
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
}
onRefresh={vm.refresh}
/>
))}
</ul>
)}
{root && openAgentId && (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-muted">Agent</span>
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
Fermer
</Button>
</div>
<WebAgentCell key={openAgentId} projectId={projectId} agentId={openAgentId} cwd={root} />
</div>
)}
</Panel>
);
}
/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */
function AgentLiveRow({
agent,
open,
onToggleOpen,
onRefresh,
}: {
agent: AgentWorkState;
open: boolean;
onToggleOpen: () => void;
onRefresh: () => Promise<void>;
}) {
const live = agent.live !== undefined;
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 (
<li className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs">
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
{live ? "Live" : "Offline"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-medium", busy ? "bg-warning/15 text-warning" : "bg-raised text-muted")}>
{busy ? "Busy" : "Idle"}
</span>
<Button variant="ghost" size="sm" aria-pressed={open} onClick={onToggleOpen}>
{open ? "Fermer" : "Ouvrir"}
</Button>
</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>
))}
</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>
);
}