Files
IdeA/frontend/src/features/web/WebWorkspace.tsx
Blomios da911d5a9b fix(chat): états vides explicites et reroutage vers le fil actif quand Work state signale un agent busy — #238
ConversationViewer remplace « Aucun tour pour le moment » par un diagnostic
differentié (agent actif sans tour affichable, transcript non synchronisé,
activité sur un autre fil) avec action « Ouvrir le fil actif ». Le panneau
Work state affiche le résumé du fil du ticket actif et ProjectsView/WebWorkspace
câblent onOpenConversation.
2026-08-22 11:30:38 +02:00

1251 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<Project[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
const [focusSprintId, setFocusSprintId] = useState<string | null>(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<Project> => {
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 (
<WebMobileTaskFlowShell
panel={panel}
projects={projects}
openId={openId}
openProject={openProject}
openRoot={openRoot}
error={error}
focusSprintId={focusSprintId}
onSelectPanel={selectPanel}
onOpenProject={(id) => void openReadOnly(id)}
onCreateProject={createProject}
onRefreshProjects={() => void refresh()}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
selectPanel("tickets");
}}
/>
);
}
return (
<WebDesktopWorkspace
projects={projects}
openId={openId}
openProject={openProject}
openRoot={openRoot}
error={error}
focusSprintId={focusSprintId}
onSelectPanel={selectPanel}
onOpenProject={(id) => 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<Record<WebDesktopPanelId, ViewPlacement>>;
const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
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<Project>;
onRefreshProjects: () => void;
onViewSprintTickets: (sprintId: string) => void;
}) {
const [placements, setPlacements] = useState<WebDesktopPlacements>({
work: { dock: "right" },
});
const [leftDockWidth, setLeftDockWidth] = useState(340);
const [rightDockWidth, setRightDockWidth] = useState(340);
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
const [viewerConversation, setViewerConversation] = useState<{
projectId: string;
conversationId: string;
} | null>(null);
useEffect(() => {
setActiveLayout(null);
setViewerConversation(null);
}, [openId]);
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
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 = (
<WebProjectsPanel
projects={projects}
openId={openId}
onOpen={onOpenProject}
onCreate={onCreateProject}
onRefresh={onRefreshProjects}
/>
);
function renderPanel(panel: WebDesktopPanelId) {
if (panel === "projects") return projectsManager;
if (!openId) {
return <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
}
return (
<WebProjectPanelBody
panel={panel as WebOpenablePanelId}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={onViewSprintTickets}
/>
);
}
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 (
<div className="flex shrink-0 items-center gap-1">
<IconButton
size="sm"
title="Ancré à gauche"
aria-label={`Ancré à gauche — ${title}`}
aria-pressed={isDockedTo(placement, "left")}
className={cn(isDockedTo(placement, "left") && activeClass)}
onClick={() => setPlacement(panel, { dock: "left" })}
>
⇤
</IconButton>
<IconButton
size="sm"
title="Ancré à droite"
aria-label={`Ancré à droite — ${title}`}
aria-pressed={isDockedTo(placement, "right")}
className={cn(isDockedTo(placement, "right") && activeClass)}
onClick={() => setPlacement(panel, { dock: "right" })}
>
⇥
</IconButton>
<IconButton
size="sm"
title="Flottant"
aria-label={`Flottant — ${title}`}
aria-pressed={placement === "floating"}
className={cn(placement === "floating" && activeClass)}
onClick={() => setPlacement(panel, "floating")}
>
□
</IconButton>
<IconButton
size="sm"
title="Fermé"
aria-label={`Fermé — ${title}`}
onClick={() => closePanel(panel)}
>
×
</IconButton>
</div>
);
}
function renderDockedView(panel: WebDesktopPanelId) {
return (
<section
key={panel}
aria-label={`${webPanelTitle(panel)} panel`}
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
>
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="truncate text-sm font-medium text-content">
{webPanelTitle(panel)}
</span>
<DockControls panel={panel} />
</header>
<div className="min-h-0 flex-1 overflow-auto p-3">{renderPanel(panel)}</div>
</section>
);
}
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 (
<div
data-testid="web-workspace"
data-shell="desktop-layout"
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
>
<header className="flex h-12 shrink-0 items-center justify-between gap-4 border-b border-border bg-surface px-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-content">
{openProject ? openProject.name : "Aucun projet ouvert"}
</p>
<p className="truncate text-xs text-faint">
{openProject?.root ?? "Ouvrir un projet pour accéder aux layouts."}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
onSelectPanel("projects");
setPlacement("projects", "floating");
}}
>
Projets
</Button>
</header>
<MenuBar menus={menus} />
<ReconnectBanner />
{error && (
<div className="shrink-0 px-4 py-2">
<WebWorkspaceError message={error} />
</div>
)}
<div className="flex min-h-0 flex-1 overflow-hidden">
{leftPanels.length > 0 && (
<DockRegion
side="left"
width={leftDockWidth}
onResize={setLeftDockWidth}
aria-label="left dock"
>
{leftPanels.map(renderDockedView)}
</DockRegion>
)}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{openProject ? (
<>
<LayoutTabs
projectId={openProject.id}
onActiveLayoutChange={setActiveLayout}
/>
{viewerConversation ? (
<ConversationViewer
key={`${viewerConversation.projectId}-${viewerConversation.conversationId}`}
projectId={viewerConversation.projectId}
conversationId={viewerConversation.conversationId}
onClose={() => setViewerConversation(null)}
onOpenConversation={openConversation}
/>
) : activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${openProject.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={openProject.id}
/>
) : (
<LayoutGrid
key={`${openProject.id}-${activeLayout?.id ?? "default"}`}
projectId={openProject.id}
cwd={openProject.root}
layoutId={activeLayout?.id}
onOpenConversation={openConversation}
/>
)}
</>
) : (
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-xl">
{showInlineProjects ? (
projectsManager
) : (
<p className="text-sm text-muted">
Sélectionner ou créer un projet pour démarrer.
</p>
)}
</div>
</div>
)}
</main>
{rightPanels.length > 0 && (
<DockRegion
side="right"
width={rightDockWidth}
onResize={setRightDockWidth}
aria-label="right dock"
>
{rightPanels.map(renderDockedView)}
</DockRegion>
)}
</div>
{floatingList.map((panel) => (
<FloatingWindow
key={panel}
open
title={webPanelTitle(panel)}
size={WEB_PANEL_SIZE[panel]}
onClose={() => closePanel(panel)}
>
<div className="mb-3 flex justify-end border-b border-border pb-3">
<DockControls panel={panel} />
</div>
{renderPanel(panel)}
</FloatingWindow>
))}
</div>
);
}
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<Project>;
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 (
<div
data-testid="web-workspace"
data-shell="mobile-task-flow"
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
>
<header
data-testid="web-mobile-task-header"
className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]"
>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-faint">
{WEB_PANEL_META[activePanel].label}
</p>
<p className="truncate text-sm font-medium text-content">
{openProject ? openProject.name : "Choisir un projet"}
</p>
</div>
<MobileProjectSelector
projects={projects}
openId={openId}
onOpenProject={onOpenProject}
onShowProjects={() => onSelectPanel("projects")}
onRefreshProjects={onRefreshProjects}
/>
</header>
<main
data-testid="web-mobile-task-canvas"
className="min-h-0 flex-1 overflow-y-auto px-3 pb-4 pt-3"
>
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<ReconnectBanner />
{error && <WebWorkspaceError message={error} />}
<WebMobileTaskSurface
panel={activePanel}
projects={projects}
openId={openId}
openRoot={openRoot}
focusSprintId={focusSprintId}
onOpenProject={onOpenProject}
onCreateProject={onCreateProject}
onRefreshProjects={onRefreshProjects}
onViewSprintTickets={onViewSprintTickets}
/>
</div>
</main>
<nav
data-testid="web-mobile-bottom-nav"
aria-label="Navigation mobile"
className="grid shrink-0 grid-cols-6 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]"
>
{navItems.map((item) => {
const disabled = item.id !== "projects" && !openId;
const selected = activePanel === item.id;
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-current={selected ? "page" : undefined}
onClick={() => onSelectPanel(item.id)}
className={cn(
"flex min-h-14 items-center justify-center px-1 text-xs font-medium",
selected ? "text-primary" : "text-muted",
disabled && "cursor-not-allowed opacity-45",
)}
>
{item.label}
</button>
);
})}
</nav>
</div>
);
}
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 (
<>
<Button size="sm" variant="ghost" onClick={() => setOpen(true)}>
Projets
</Button>
{open && (
<WebMenuSheet
title="Projets"
note={projects === null ? "Chargement des projets…" : undefined}
onClose={() => 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<Project>;
onRefreshProjects: () => void;
onViewSprintTickets: (sprintId: string) => void;
}) {
if (panel === "projects") {
return (
<WebProjectsPanel
projects={projects}
openId={openId}
onOpen={onOpenProject}
onCreate={onCreateProject}
onRefresh={onRefreshProjects}
/>
);
}
if (!openId) {
return (
<Panel className="mt-2">
<p className="text-sm text-muted">Choisir un projet pour continuer.</p>
</Panel>
);
}
return (
<WebProjectPanelBody
panel={panel}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={onViewSprintTickets}
mobile
/>
);
}
function WebWorkspaceError({ message }: { message: string }) {
return (
<Panel className="border-danger/40">
<p className="text-sm text-danger">{message}</p>
</Panel>
);
}
/** 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 <ProjectContextPanel projectId={projectId} />;
case "work":
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
case "tickets":
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
case "sprints":
return (
<WebSprintsView projectId={projectId} onViewSprintTickets={onViewSprintTickets} />
);
case "agents":
return <AgentsPanel projectId={projectId} projectRoot={root ?? ""} />;
case "templates":
return <TemplatesPanel projectId={projectId} />;
case "skills":
return <SkillsPanel projectId={projectId} />;
case "permissions":
return <PermissionsPanel projectId={projectId} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={projectId} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={projectId} />;
case "files":
return <ProjectFilesPanel projectId={projectId} mobile={mobile} />;
}
}
/**
* 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 (
<div
role="status"
data-testid="web-reconnect-banner"
className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<Spinner size={12} />
Connexion perdue — reconnexion en cours…
</div>
);
}
/** 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<string | null>(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<Set<string>>(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 (
<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));
bumpCellLayoutVersion();
}}
onRefresh={vm.refresh}
bgExpanded={expandedBgAgents.has(a.agentId)}
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
/>
))}
</ul>
)}
{root && openAgentId && !mobile && (
<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);
bumpCellLayoutVersion();
}}
>
Fermer
</Button>
</div>
<WebAgentCell
key={openAgentId}
projectId={projectId}
agentId={openAgentId}
cwd={root}
refitSignal={cellLayoutVersion}
/>
</div>
)}
{root && openAgentId && mobile && (
<div
role="dialog"
aria-modal="true"
aria-label={`Session agent ${openAgent?.name ?? openAgentId}`}
data-testid="web-mobile-agent-session"
className="fixed inset-0 z-50 flex min-h-0 flex-col bg-canvas"
>
<header className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-faint">Session agent</p>
<p className="truncate text-sm font-medium text-content">
{openAgent?.name ?? openAgentId}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOpenAgentId(null);
bumpCellLayoutVersion();
}}
>
Fermer
</Button>
</header>
<div className="min-h-0 flex-1 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<WebAgentCell
key={openAgentId}
projectId={projectId}
agentId={openAgentId}
cwd={root}
refitSignal={cellLayoutVersion}
fullHeight
/>
</div>
</div>
)}
</Panel>
);
}
/** 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<void>;
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 (
<li className="py-2 first:pt-0 last:pb-0">
{/* #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. */}
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm: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">
<button
type="button"
aria-expanded={bgExpanded}
aria-controls={`web-bg-tasks-${agent.agentId}`}
aria-label={
bgExpanded
? `Masquer les background tasks de ${agent.name}`
: `Afficher les background tasks de ${agent.name}`
}
onClick={onToggleBgExpanded}
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint hover:text-muted"
>
<span aria-hidden="true">{bgExpanded ? "▾" : "▸"}</span>
Background tasks
<span className="rounded-full bg-raised px-1.5 py-0.5 font-medium normal-case tracking-normal text-muted">
{backgroundTasks.length}
</span>
</button>
{bgExpanded && (
<ul
id={`web-bg-tasks-${agent.agentId}`}
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",
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<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">
{/* #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. */}
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<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>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
<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>
</div>
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
</li>
);
}