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>
This commit is contained in:
@ -1,23 +1,33 @@
|
||||
/**
|
||||
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
|
||||
* Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces).
|
||||
*
|
||||
* 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`.
|
||||
* 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`.
|
||||
*
|
||||
* 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.
|
||||
* 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 { GatewayError, Project, ProjectWorkState } from "@/domain";
|
||||
import type {
|
||||
AgentWorkState,
|
||||
BackgroundCompletion,
|
||||
GatewayError,
|
||||
Project,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
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) {
|
||||
@ -27,14 +37,10 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function WebWorkspace() {
|
||||
const { project, workState } = useGateways();
|
||||
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 [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;
|
||||
|
||||
@ -54,22 +60,17 @@ export function WebWorkspace() {
|
||||
const openReadOnly = useCallback(
|
||||
async (projectId: string) => {
|
||||
setError(null);
|
||||
setLoadingSnapshot(true);
|
||||
setOpenId(projectId);
|
||||
setSnapshot(null);
|
||||
setOpenAgentId(null);
|
||||
setOpenId(null);
|
||||
try {
|
||||
// Read-only: open resolves the project server-side, then we read the
|
||||
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
||||
// 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);
|
||||
setSnapshot(await workState.getProjectWorkState(projectId));
|
||||
setOpenId(projectId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setLoadingSnapshot(false);
|
||||
}
|
||||
},
|
||||
[project, workState],
|
||||
[project],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -112,89 +113,205 @@ export function WebWorkspace() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<LiveProjectPanel projectId={openId} root={openRoot} />
|
||||
)}
|
||||
</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>;
|
||||
}
|
||||
/** 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 (
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user