/** * 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(null); const [error, setError] = useState(null); const [openId, setOpenId] = useState(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 (

Projets

{error && (

{error}

)} {projects === null ? ( Chargement des projets… ) : projects.length === 0 ? (

Aucun projet.

) : (
    {projects.map((p) => (
  • ))}
)} {openId && ( )}
); } /** 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(null); const agents = vm.state?.agents ?? []; return (

État live read-only

{vm.error &&

{vm.error}

} {vm.busy && vm.state === null ? (

Chargement de l'état…

) : agents.length === 0 ? (

Aucun agent actif.

) : (
    {agents.map((a) => ( setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)) } onRefresh={vm.refresh} /> ))}
)} {root && openAgentId && (
Agent
)}
); } /** 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; }) { 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 (
  • {agent.name} {live ? "Live" : "Offline"} {busy ? "Busy" : "Idle"}
    {inbox.length > 0 && (

    Inbox

      {inbox.map((item) => (
    • {item.kind} {item.body}
    • ))}
    )} {backgroundTasks.length > 0 && (

    Background tasks

      {backgroundTasks.map((task) => ( ))}
    )}
  • ); } const TASK_STATUS_LABEL: Record = { 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; }) { const { workState } = useGateways(); const [actionBusy, setActionBusy] = useState(false); const [message, setMessage] = useState(null); const canCancel = task.status === "running" || task.status === "pending"; const canRetry = task.status === "failed" || task.status === "cancelled"; async function runAction(action: (taskId: string) => Promise): Promise { setActionBusy(true); setMessage(null); try { await action(task.taskId); await onRefresh(); } catch (e) { setMessage(describe(e)); } finally { setActionBusy(false); } } return (
  • {TASK_STATUS_LABEL[task.status]} {task.kind}
    {message &&

    {message}

    }
  • ); }