/** * Live web workspace — ticket #13/#86, extended by #90 (missing menus) * and #203/#204 (desktop shell parity + browser-openable surfaces). * * After pairing: list/create projects (web variant — no native folder browse, * see {@link WebProjectsPanel}), open one project, and split the shell by * viewport. Desktop web reuses the AppImage layout system (`LayoutTabs`, * `LayoutGrid`, floating/docked panels) without OS-detached windows. Mobile web * keeps the simplified task-flow shell. * * 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 on desktop. The mobile * panel's own "Ouvrir" still mounts {@link WebAgentCell}, a touch-drivable * terminal cell (F4), 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, useMemo, useState } from "react"; import type { AgentWorkState, BackgroundCompletion, GatewayError, LayoutInfo, Project, } from "@/domain"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel"; import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement"; 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 { GitGraphView, GitPanel } from "@/features/git"; import { ConversationViewer } from "@/features/conversations"; import { Button, DockRegion, FloatingWindow, IconButton, MenuBar, Panel, Spinner, cn, type FloatingWindowSize, type MenuBarItem, type MenuBarMenu, } 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"; import { ProjectFilesPanel } from "./ProjectFilesPanel"; import { WEB_PANEL_META, type WebOpenablePanelId, type WebProjectPanelId, } from "./webSurfaceCatalog"; 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 [panel, setPanel] = useState("projects"); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = useState(null); const mobile = useMobileWorkspace(); const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; const openProject = useMemo( () => projects?.find((p) => p.id === openId) ?? null, [openId, projects], ); const refresh = useCallback(async () => { setError(null); try { setProjects(await project.listProjects()); } catch (e) { setError(describe(e)); } }, [project]); useEffect(() => { void refresh(); }, [refresh]); const selectPanel = useCallback( (nextPanel: WebProjectPanelId) => { setPanel(nextPanel); }, [], ); 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], ); if (mobile) { return ( void openReadOnly(id)} onCreateProject={createProject} onRefreshProjects={() => void refresh()} onViewSprintTickets={(sprintId) => { setFocusSprintId(sprintId); selectPanel("tickets"); }} /> ); } return ( void openReadOnly(id)} onCreateProject={createProject} onRefreshProjects={() => void refresh()} onViewSprintTickets={(sprintId) => { setFocusSprintId(sprintId); selectPanel("tickets"); }} /> ); } function useMobileWorkspace(): boolean { const read = () => (typeof window === "undefined" ? false : window.innerWidth < 768); const [mobile, setMobile] = useState(read); useEffect(() => { const onResize = () => setMobile(read()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); return mobile; } type WebDesktopPanelId = WebProjectPanelId; type WebDesktopPlacements = Partial>; const WEB_PANEL_SIZE: Record = { projects: "md", context: "lg", work: "lg", tickets: "lg", sprints: "lg", agents: "lg", templates: "lg", skills: "lg", permissions: "md", memory: "lg", git: "lg", files: "lg", }; function webPanelTitle(panel: WebDesktopPanelId): string { return WEB_PANEL_META[panel].label; } function webPlacementOf( placements: WebDesktopPlacements, panel: WebDesktopPanelId, ): ViewPlacement { return placements[panel] ?? "closed"; } function webPanelsDockedTo( placements: WebDesktopPlacements, side: "left" | "right", ): WebDesktopPanelId[] { return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter((panel) => isDockedTo(webPlacementOf(placements, panel), side), ); } function webFloatingPanels(placements: WebDesktopPlacements): WebDesktopPanelId[] { return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter( (panel) => webPlacementOf(placements, panel) === "floating", ); } function WebDesktopWorkspace({ projects, openId, openProject, openRoot, error, focusSprintId, onSelectPanel, onOpenProject, onCreateProject, onRefreshProjects, onViewSprintTickets, }: { projects: Project[] | null; openId: string | null; openProject: Project | null; openRoot: string | null; error: string | null; focusSprintId: string | null; onSelectPanel: (panel: WebProjectPanelId) => void; onOpenProject: (projectId: string) => void; onCreateProject: (name: string, root: string) => Promise; onRefreshProjects: () => void; onViewSprintTickets: (sprintId: string) => void; }) { const [placements, setPlacements] = useState({ work: { dock: "right" }, }); const [leftDockWidth, setLeftDockWidth] = useState(340); const [rightDockWidth, setRightDockWidth] = useState(340); const [activeLayout, setActiveLayout] = useState(null); const [viewerConversation, setViewerConversation] = useState<{ projectId: string; conversationId: string; } | null>(null); useEffect(() => { setActiveLayout(null); }, [openId]); const activeViewerConversation = viewerConversation?.projectId === openProject?.id ? viewerConversation : null; function setPlacement(panel: WebDesktopPanelId, placement: Exclude) { setPlacements((prev) => { const next: WebDesktopPlacements = { ...prev }; if (placement === "floating") { for (const other of Object.keys(next) as WebDesktopPanelId[]) { if (other !== panel && next[other] === "floating") delete next[other]; } } next[panel] = placement; return next; }); } function closePanel(panel: WebDesktopPanelId) { setPlacements((prev) => { if (!(panel in prev)) return prev; const next = { ...prev }; delete next[panel]; return next; }); } function dismissFloating() { setPlacements((prev) => { let changed = false; const next = { ...prev }; for (const panel of Object.keys(next) as WebDesktopPanelId[]) { if (next[panel] === "floating") { delete next[panel]; changed = true; } } return changed ? next : prev; }); } function openConversation(conversationId: string) { if (!openProject) return; setViewerConversation({ projectId: openProject.id, conversationId }); dismissFloating(); } const panelOrder: WebDesktopPanelId[] = [ "context", "work", "tickets", "sprints", "agents", "templates", "skills", "permissions", "memory", "git", "files", "projects", ]; function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] { const placement = webPlacementOf(placements, panel); return [ { id: "closed", label: "Fermé", active: placement === "closed", onSelect: () => closePanel(panel), }, { id: "floating", label: "Flottant", active: placement === "floating", onSelect: () => setPlacement(panel, "floating"), }, { id: "dock-left", label: "Ancré à gauche", active: isDockedTo(placement, "left"), onSelect: () => setPlacement(panel, { dock: "left" }), }, { id: "dock-right", label: "Ancré à droite", active: isDockedTo(placement, "right"), onSelect: () => setPlacement(panel, { dock: "right" }), }, ]; } const menus: MenuBarMenu[] = [ { id: "panels", label: "Panneaux", items: panelOrder.map((panel) => ({ id: panel, label: webPanelTitle(panel), active: webPlacementOf(placements, panel) !== "closed", onSelect: () => {}, submenu: placementSubmenu(panel), })), }, ]; const projectsManager = ( ); function renderPanel(panel: WebDesktopPanelId) { if (panel === "projects") return projectsManager; if (!openId) { return

Ouvrir un projet pour utiliser ce panneau.

; } return ( ); } function DockControls({ panel }: { panel: WebDesktopPanelId }) { const placement = webPlacementOf(placements, panel); const title = webPanelTitle(panel); const activeClass = "bg-raised text-content ring-1 ring-border-strong"; return (
setPlacement(panel, { dock: "left" })} > ⇤ setPlacement(panel, { dock: "right" })} > ⇥ setPlacement(panel, "floating")} > □ closePanel(panel)} > ×
); } function renderDockedView(panel: WebDesktopPanelId) { return (
{webPanelTitle(panel)}
{renderPanel(panel)}
); } const leftPanels = webPanelsDockedTo(placements, "left"); const rightPanels = webPanelsDockedTo(placements, "right"); const floatingList = webFloatingPanels(placements); const activeLayoutKind = activeLayout?.kind ?? "terminal"; const showInlineProjects = !openProject && webPlacementOf(placements, "projects") === "closed"; return (

{openProject ? openProject.name : "Aucun projet ouvert"}

{openProject?.root ?? "Ouvrir un projet pour accéder aux layouts."}

{error && (
)}
{leftPanels.length > 0 && ( {leftPanels.map(renderDockedView)} )}
{openProject ? ( <> {activeViewerConversation ? ( setViewerConversation(null)} onOpenConversation={openConversation} /> ) : activeLayoutKind === "gitGraph" ? ( ) : ( )} ) : (
{showInlineProjects ? ( projectsManager ) : (

Sélectionner ou créer un projet pour démarrer.

)}
)}
{rightPanels.length > 0 && ( {rightPanels.map(renderDockedView)} )}
{floatingList.map((panel) => ( closePanel(panel)} >
{renderPanel(panel)}
))}
); } function WebMobileTaskFlowShell({ panel, projects, openId, openProject, openRoot, error, focusSprintId, onSelectPanel, onOpenProject, onCreateProject, onRefreshProjects, onViewSprintTickets, }: { panel: WebProjectPanelId; projects: Project[] | null; openId: string | null; openProject: Project | null; openRoot: string | null; error: string | null; focusSprintId: string | null; onSelectPanel: (panel: WebProjectPanelId) => void; onOpenProject: (projectId: string) => void; onCreateProject: (name: string, root: string) => Promise; onRefreshProjects: () => void; onViewSprintTickets: (sprintId: string) => void; }) { const activePanel = panel === "projects" || openId ? panel : "projects"; const navItems: Array<{ id: WebProjectPanelId; label: string }> = [ { id: "projects", label: "Projet" }, { id: "work", label: "Travail" }, { id: "tickets", label: "Tickets" }, { id: "sprints", label: "Sprints" }, { id: "files", label: "Fichiers" }, { id: "agents", label: "Agent" }, ]; return (

{WEB_PANEL_META[activePanel].label}

{openProject ? openProject.name : "Choisir un projet"}

onSelectPanel("projects")} onRefreshProjects={onRefreshProjects} />
{error && }
); } function MobileProjectSelector({ projects, openId, onOpenProject, onShowProjects, onRefreshProjects, }: { projects: Project[] | null; openId: string | null; onOpenProject: (projectId: string) => void; onShowProjects: () => void; onRefreshProjects: () => void; }) { const [open, setOpen] = useState(false); return ( <> {open && ( setOpen(false)} entries={[ ...(projects ?? []).map((project) => ({ id: project.id, label: project.name, hint: project.root, active: openId === project.id, onSelect: () => { onOpenProject(project.id); setOpen(false); }, })), { id: "manage-projects", label: "Gérer les projets", hint: "Créer ou rafraîchir", active: false, onSelect: () => { onShowProjects(); setOpen(false); }, }, { id: "refresh-projects", label: "Rafraîchir", active: false, onSelect: () => { onRefreshProjects(); setOpen(false); }, }, ]} /> )} ); } function WebMobileTaskSurface({ panel, projects, openId, openRoot, focusSprintId, onOpenProject, onCreateProject, onRefreshProjects, onViewSprintTickets, }: { panel: WebProjectPanelId; projects: Project[] | null; openId: string | null; openRoot: string | null; focusSprintId: string | null; onOpenProject: (projectId: string) => void; onCreateProject: (name: string, root: string) => Promise; onRefreshProjects: () => void; onViewSprintTickets: (sprintId: string) => void; }) { if (panel === "projects") { return ( ); } if (!openId) { return (

Choisir un projet pour continuer.

); } return ( ); } function WebWorkspaceError({ message }: { message: string }) { return (

{message}

); } /** Renders the requested panel's body for the opened project. */ export function WebProjectPanelBody({ panel, projectId, root, focusSprintId, onViewSprintTickets, mobile = false, }: { panel: WebOpenablePanelId; projectId: string; root: string | null; focusSprintId: string | null; onViewSprintTickets: (sprintId: string) => void; mobile?: boolean; }) { 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 ; case "files": 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, mobile = false, }: { projectId: string; root: string | null; mobile?: boolean; }) { 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); // Web equivalent of desktop `useLayout.layoutVersion` (ticket #61 web // regression): the web shell has no `useLayout`/`LayoutGrid`, so nothing was // ever bumping `refitSignal` on `TerminalView` — a cell opened after another // one was left with stale xterm scaling, with no window-resize equivalent to // "repair" it on mobile. Bumped on every cell open/close and forwarded as // `refitSignal` to every visible `WebAgentCell` — a single shared counter, so // it already covers several simultaneously-visible cells if this panel ever // grows one. const [cellLayoutVersion, setCellLayoutVersion] = useState(0); const bumpCellLayoutVersion = useCallback(() => setCellLayoutVersion((v) => v + 1), []); 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; }); }, []); const openAgent = openAgentId ? agents.find((a) => a.agentId === openAgentId) : null; 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)); bumpCellLayoutVersion(); }} onRefresh={vm.refresh} bgExpanded={expandedBgAgents.has(a.agentId)} onToggleBgExpanded={() => toggleBgExpanded(a.agentId)} /> ))}
)} {root && openAgentId && !mobile && (
Agent
)} {root && openAgentId && mobile && (

Session agent

{openAgent?.name ?? openAgentId}

)}
); } /** 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", cancelling: "Cancelling", completed: "Completed", failed: "Failed", cancelFailed: "Cancel failed", cancelled: "Cancelled", pending: "Pending", delivered: "Delivered", }; function taskStatusClass(status: BackgroundCompletion["status"]): string { if (status === "running" || status === "pending" || status === "cancelling") { return "bg-warning/15 text-warning"; } if (status === "completed" || status === "delivered") return "bg-success/10 text-success"; if (status === "failed" || status === "cancelFailed" || 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}

    }
  • ); }