/** * Live web workspace — ticket #13/#86, extended by #90 (missing menus). * * After pairing: list/create projects (web variant — no native folder browse, * see {@link WebProjectsPanel}), open one read-only, and route the main surface * through the **"Panneaux" menu** (#90) across the transport-neutral panels also * used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/ * skills/permissions/memory/git/projects) plus the web-specific live work-state * view below. Selecting a panel replaces the main surface — never a floating * window or dock (desktop-only chrome, out of scope for web, per Architect). * * The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted * implementation rather than the desktop `ProjectWorkStatePanel`: that component's * "open" affordance attaches into a `LayoutGrid` cell, which the web client never * mounts (#69 pins this — no `layout-*` grid on web). This panel's own "Ouvrir" * instead mounts {@link WebAgentCell}, a touch-drivable terminal cell (F4) — the * mobile equivalent, kept transport-neutral via the same {@link useProjectWorkState} * hook and {@link WorkStateGateway} writes (cancel/retry). {@link useLiveReconnect} * re-synchronises the read-model after a WS outage. */ 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 { ProjectContextPanel } from "@/features/projects/ProjectContextPanel"; import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { PermissionsPanel } from "@/features/permissions"; import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; import { GitPanel } from "@/features/git"; import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; import { useLiveConnectionState } from "./useLiveConnectionState"; import { WebTicketsView } from "./tickets/WebTicketsView"; import { WebSprintsView } from "./tickets/WebSprintsView"; import { WebProjectsPanel } from "./WebProjectsPanel"; import { WebMenuSheet } from "./WebMenuSheet"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** The panels reachable from the web "Panneaux" menu (#90). */ export type WebProjectPanelId = | "context" | "work" | "tickets" | "sprints" | "agents" | "templates" | "skills" | "permissions" | "memory" | "git" | "projects"; /** Menu order + French labels (ticket #78 — the human UI defaults to French). */ const WEB_PANEL_ORDER: WebProjectPanelId[] = [ "context", "work", "tickets", "sprints", "agents", "templates", "skills", "permissions", "memory", "git", "projects", ]; const WEB_PANEL_LABEL: Record = { context: "Contexte projet", work: "Travail", tickets: "Tickets", sprints: "Sprints", agents: "Agents", templates: "Templates", skills: "Skills", permissions: "Permissions", memory: "Mémoire", git: "Git", projects: "Projets", }; export function WebWorkspace() { const { project } = useGateways(); const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); const [panel, setPanel] = useState("projects"); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = 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); setPanel("work"); } catch (e) { setError(describe(e)); } }, [project], ); const createProject = useCallback( async (name: string, root: string): Promise => { setError(null); try { const created = await project.createProject(name, root); await refresh(); return created; } catch (e) { setError(describe(e)); throw e; } }, [project, refresh], ); return (
{error && (

{error}

)} {panel === "projects" ? ( void openReadOnly(id)} onCreate={createProject} onRefresh={() => void refresh()} /> ) : !openId ? (

Ouvrir un projet pour utiliser ce panneau.

) : ( { setFocusSprintId(sprintId); setPanel("tickets"); }} /> )}
); } /** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */ function WebPanelMenuButton({ activePanel, hasProject, onSelect, }: { activePanel: WebProjectPanelId; hasProject: boolean; onSelect: (panel: WebProjectPanelId) => void; }) { const [open, setOpen] = useState(false); return (
{open && ( setOpen(false)} entries={WEB_PANEL_ORDER.map((id) => { const disabled = id !== "projects" && !hasProject; return { id, label: WEB_PANEL_LABEL[id], active: id === activePanel, disabled, hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined, onSelect: () => { onSelect(id); setOpen(false); }, }; })} /> )}
); } /** Renders the requested panel's body for the opened project. */ function WebPanelBody({ panel, projectId, root, focusSprintId, onViewSprintTickets, }: { panel: Exclude; projectId: string; root: string | null; focusSprintId: string | null; onViewSprintTickets: (sprintId: string) => void; }) { switch (panel) { case "context": return ; case "work": return ; case "tickets": return ; case "sprints": return ( ); case "agents": return ; case "templates": return ; case "skills": return ; case "permissions": return ; case "memory": return (
); case "git": return ; } } /** * Global reconnection banner (F6). The F3 terminal path writes a "déconnecté" * notice into xterm, but live-only surfaces (no terminal open) had no visible * signal. This surfaces the shared live socket's `reconnecting` state so the user * always knows the view may be momentarily stale; `useLiveReconnect` re-syncs the * read-model on recovery. Inert on desktop (hook returns `null`). */ function ReconnectBanner() { const connection = useLiveConnectionState(); if (connection !== "reconnecting") return null; return (
Connexion perdue — reconnexion en cours…
); } /** 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 ?? []; // Per-agent background-tasks disclosure (collapsed by default). A live refresh // must never flip this — only drop an entry once its agent has no tasks left, // so state doesn't leak for agents that no longer have a background section. const [expandedBgAgents, setExpandedBgAgents] = useState>(new Set()); useEffect(() => { const withTasks = new Set( agents.filter((a) => (a.backgroundTasks ?? []).length > 0).map((a) => a.agentId), ); setExpandedBgAgents((cur) => { const next = new Set([...cur].filter((id) => withTasks.has(id))); return next.size === cur.size ? cur : next; }); }, [agents]); const toggleBgExpanded = useCallback((agentId: string) => { setExpandedBgAgents((cur) => { const next = new Set(cur); if (next.has(agentId)) next.delete(agentId); else next.add(agentId); return next; }); }, []); 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} bgExpanded={expandedBgAgents.has(a.agentId)} onToggleBgExpanded={() => toggleBgExpanded(a.agentId)} /> ))}
)} {root && openAgentId && (
Agent
)}
); } /** One agent row: live/idle/busy + inbox + background tasks + open affordance. */ function AgentLiveRow({ agent, open, onToggleOpen, onRefresh, bgExpanded, onToggleBgExpanded, }: { agent: AgentWorkState; open: boolean; onToggleOpen: () => void; onRefresh: () => Promise; bgExpanded: boolean; onToggleBgExpanded: () => 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 (
  • {/* #69 — at 360px the name + two badges + the button no longer fit on one line, so the name takes its own row on phones and the status cluster wraps under it. From `sm` up this collapses back to the original single-line desktop layout. */}
    {agent.name} {live ? "Live" : "Offline"} {busy ? "Busy" : "Idle"}
    {inbox.length > 0 && (

    Inbox

      {inbox.map((item) => (
    • {item.kind} {item.body}
    • ))}
    )} {backgroundTasks.length > 0 && (
    {bgExpanded && (
      {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 (
  • {/* #69 — status + kind + Cancel + Retry overflow a phone row. On narrow screens the status/kind pair keeps the first line and the two actions wrap onto a second, right-aligned one; `sm` restores the single row. */}
    {TASK_STATUS_LABEL[task.status]} {task.kind}
    {message &&

    {message}

    }
  • ); }