feat(frontend): ajoute les menus Panneaux/Paramètres à la version web (#90)

WebWorkspace gagne un menu « Panneaux » qui route la surface principale du
projet ouvert vers les panneaux transport-neutres déjà réutilisables
(contexte, agents, templates, skills, permissions, mémoire, git) en plus des
surfaces web existantes (travail live, tickets, sprints), ainsi qu'un variant
web du panneau Projets (création par chemin serveur saisi manuellement, sans
browse natif). WebApp gagne un menu « Paramètres » (Profils IA, Appareils,
Déploiement désactivé "Desktop uniquement") qui remplace l'ancien bouton
unique "Appareils".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 18:33:22 +02:00
parent 678ff3011b
commit 30f6415d51
7 changed files with 710 additions and 167 deletions

View File

@ -1,18 +1,22 @@
/**
* Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces).
* Live web workspace — ticket #13/#86, extended by #90 (missing menus).
*
* 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`.
* 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).
*
* 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.
* 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";
@ -25,12 +29,22 @@ import type {
} 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) {
@ -39,11 +53,57 @@ function describe(e: unknown): string {
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<WebProjectPanelId, string> = {
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<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 openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
@ -69,6 +129,7 @@ export function WebWorkspace() {
// its work-state. No layout/PTY is mounted here.
await project.openProject(projectId);
setOpenId(projectId);
setPanel("work");
} catch (e) {
setError(describe(e));
}
@ -76,18 +137,29 @@ export function WebWorkspace() {
[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],
);
return (
<div
data-testid="web-workspace"
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
>
<ReconnectBanner />
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
Rafraîchir
</Button>
</div>
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
{error && (
<Panel className="border-danger/40">
@ -95,134 +167,120 @@ export function WebWorkspace() {
</Panel>
)}
{projects === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des projets
</span>
) : projects.length === 0 ? (
<p className="text-sm text-muted">Aucun projet.</p>
{panel === "projects" ? (
<WebProjectsPanel
projects={projects}
openId={openId}
onOpen={(id) => void openReadOnly(id)}
onCreate={createProject}
onRefresh={() => void refresh()}
/>
) : !openId ? (
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
) : (
<ul className="flex flex-col gap-2" data-testid="web-project-list">
{projects.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => void openReadOnly(p.id)}
aria-pressed={openId === p.id}
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
>
<span className="text-sm font-medium text-content">{p.name}</span>
<span className="text-xs text-faint">{p.root}</span>
</button>
</li>
))}
</ul>
)}
{openId && (
<ProjectTabs projectId={openId} root={openRoot} />
<WebPanelBody
panel={panel}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setPanel("tickets");
}}
/>
)}
</div>
);
}
type ProjectTab = "live" | "tickets" | "sprints";
const PROJECT_TABS: { id: ProjectTab; label: string }[] = [
{ id: "live", label: "Live" },
{ id: "tickets", label: "Tickets" },
{ id: "sprints", label: "Sprints" },
];
/**
* Project navigation for the web workspace (ticket #86): `Live` / `Tickets` /
* `Sprints`, a compact tablist above the project surfaces — never the desktop
* dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own
* data (own hook instance), so switching away and back re-fetches fresh rather
* than caching stale state across tabs.
*/
function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) {
const [tab, setTab] = useState<ProjectTab>("live");
// Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab.
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
/** 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 (
<div className="mt-2 flex flex-col gap-3">
<div
role="tablist"
aria-label="Navigation du projet"
className="flex gap-1 overflow-x-auto border-b border-border"
>
{PROJECT_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`web-project-tab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`web-project-tabpanel-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = PROJECT_TABS.findIndex((x) => x.id === tab);
const next =
e.key === "ArrowRight"
? (i + 1) % PROJECT_TABS.length
: (i - 1 + PROJECT_TABS.length) % PROJECT_TABS.length;
setTab(PROJECT_TABS[next].id);
}}
className={cn(
"min-h-[32px] shrink-0 rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
tab === t.id
? "border-primary text-content"
: "border-transparent text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-live"
aria-labelledby="web-project-tab-live"
hidden={tab !== "live"}
>
{tab === "live" && <LiveProjectPanel projectId={projectId} root={root} />}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-tickets"
aria-labelledby="web-project-tab-tickets"
hidden={tab !== "tickets"}
>
{tab === "tickets" && (
<WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />
)}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-sprints"
aria-labelledby="web-project-tab-sprints"
hidden={tab !== "sprints"}
>
{tab === "sprints" && (
<WebSprintsView
projectId={projectId}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setTab("tickets");
}}
/>
)}
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
Panneaux : {WEB_PANEL_LABEL[activePanel]}
</Button>
{open && (
<WebMenuSheet
title="Panneaux"
note={
!hasProject ? "Ouvrir un projet pour utiliser ce panneau." : undefined
}
onClose={() => 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);
},
};
})}
/>
)}
</div>
);
}
/** Renders the requested panel's body for the opened project. */
function WebPanelBody({
panel,
projectId,
root,
focusSprintId,
onViewSprintTickets,
}: {
panel: Exclude<WebProjectPanelId, "projects">;
projectId: string;
root: string | null;
focusSprintId: string | null;
onViewSprintTickets: (sprintId: string) => void;
}) {
switch (panel) {
case "context":
return <ProjectContextPanel projectId={projectId} />;
case "work":
return <LiveProjectPanel projectId={projectId} root={root} />;
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} />;
}
}
/**
* 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