feat(web): add desktop parity shell — #203

This commit is contained in:
2026-08-15 01:03:29 +02:00
parent 4cbb4ee7c7
commit ee2ee037f9
3 changed files with 437 additions and 50 deletions

View File

@ -8,7 +8,7 @@
* desktop-only action.
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
@ -51,6 +51,23 @@ function renderPairedWebApp(gateways: Gateways) {
}
describe("WebWorkspace « Panneaux » menu (#90)", () => {
it("renders the #203 desktop shell zones with persistent surface navigation", async () => {
renderPairedWebApp(await seededGateways());
expect(await screen.findByTestId("web-desktop-shell")).toBeTruthy();
expect(screen.getByTestId("web-shell-topbar")).toBeTruthy();
expect(screen.getByTestId("web-shell-primary-rail")).toBeTruthy();
expect(screen.getByTestId("web-shell-canvas")).toBeTruthy();
expect(screen.getByTestId("web-shell-secondary-rail")).toBeTruthy();
fireEvent.click(await screen.findByText("Demo"));
expect(await screen.findByText("Surface Travail")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Surface Tickets" }));
expect(await screen.findByText("Surface Tickets")).toBeTruthy();
expect(screen.getByRole("button", { name: "Surface Travail" })).toBeTruthy();
});
it("only enables Projets while no project is open, with a help note", async () => {
renderPairedWebApp(await seededGateways());
@ -66,7 +83,9 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
// A disabled entry does nothing.
fireEvent.click(contextEntry);
expect(screen.queryByText("Contexte projet")).toBeTruthy(); // sheet still open, menu entry still there
expect(
within(screen.getByRole("dialog", { name: "Panneaux" })).queryByText("Contexte projet"),
).toBeTruthy(); // sheet still open, menu entry still there
});
it("routes the main surface to the selected panel once a project is open", async () => {

View File

@ -86,7 +86,8 @@ describe("web client on a phone viewport", () => {
const workspace = await screen.findByTestId("web-workspace");
expect(workspace.className).toContain("flex-col");
expect(workspace.className).toContain("overflow-y-auto");
const canvas = screen.getByTestId("web-shell-canvas");
expect(canvas.className).toContain("overflow-y-auto");
});
it("gives the agent cell a key toolbar so a phone can send Ctrl-C/Esc/arrows", async () => {

View File

@ -1,5 +1,6 @@
/**
* Live web workspace — ticket #13/#86, extended by #90 (missing menus).
* Live web workspace — ticket #13/#86, extended by #90 (missing menus)
* and #203 (desktop shell parity).
*
* After pairing: list/create projects (web variant — no native folder browse,
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
@ -19,7 +20,7 @@
* re-synchronises the read-model after a WS outage.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AgentWorkState,
@ -53,7 +54,7 @@ function describe(e: unknown): string {
return String(e);
}
/** The panels reachable from the web "Panneaux" menu (#90). */
/** The surfaces reachable from the web shell navigation (#90/#203). */
export type WebProjectPanelId =
| "context"
| "work"
@ -67,6 +68,16 @@ export type WebProjectPanelId =
| "git"
| "projects";
type WebPanelPlacement = "primary" | "secondary";
interface WebPanelMeta {
id: WebProjectPanelId;
label: string;
shortLabel: string;
description: string;
placement: WebPanelPlacement;
}
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
"context",
@ -82,20 +93,90 @@ const WEB_PANEL_ORDER: WebProjectPanelId[] = [
"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",
const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
context: {
id: "context",
label: "Contexte projet",
shortLabel: "Contexte",
description: "Contexte partagé du projet ouvert",
placement: "secondary",
},
work: {
id: "work",
label: "Travail",
shortLabel: "Travail",
description: "État live des agents et tâches",
placement: "primary",
},
tickets: {
id: "tickets",
label: "Tickets",
shortLabel: "Tickets",
description: "Suivi et détail des tickets",
placement: "primary",
},
sprints: {
id: "sprints",
label: "Sprints",
shortLabel: "Sprints",
description: "Organisation des sprints",
placement: "primary",
},
agents: {
id: "agents",
label: "Agents",
shortLabel: "Agents",
description: "Catalogue et lancement des agents",
placement: "primary",
},
templates: {
id: "templates",
label: "Templates",
shortLabel: "Templates",
description: "Templates d'agents",
placement: "secondary",
},
skills: {
id: "skills",
label: "Skills",
shortLabel: "Skills",
description: "Skills assignés et éditables",
placement: "secondary",
},
permissions: {
id: "permissions",
label: "Permissions",
shortLabel: "Perms",
description: "Permissions MCP et sandbox",
placement: "secondary",
},
memory: {
id: "memory",
label: "Mémoire",
shortLabel: "Mémoire",
description: "Mémoire projet et embedder",
placement: "secondary",
},
git: {
id: "git",
label: "Git",
shortLabel: "Git",
description: "État Git du projet",
placement: "primary",
},
projects: {
id: "projects",
label: "Projets",
shortLabel: "Explorer",
description: "Liste et création de projets",
placement: "primary",
},
};
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = Object.fromEntries(
WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]),
) as Record<WebProjectPanelId, string>;
export function WebWorkspace() {
const { project } = useGateways();
const [projects, setProjects] = useState<Project[] | null>(null);
@ -106,6 +187,10 @@ export function WebWorkspace() {
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
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);
@ -155,44 +240,326 @@ export function WebWorkspace() {
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"
data-shell="desktop-parity"
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
>
<ReconnectBanner />
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
{error && (
<Panel className="border-danger/40">
<p className="text-sm text-danger">{error}</p>
</Panel>
)}
{panel === "projects" ? (
<WebProjectsPanel
projects={projects}
openId={openId}
onOpen={(id) => void openReadOnly(id)}
onCreate={createProject}
onRefresh={() => void refresh()}
<div data-testid="web-desktop-shell" className="contents">
<WebDesktopTopBar
activePanel={panel}
openProject={openProject}
projectCount={projects?.length ?? null}
onShowProjects={() => setPanel("projects")}
/>
) : !openId ? (
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
) : (
<WebPanelBody
panel={panel}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setPanel("tickets");
}}
/>
)}
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
<WebPrimaryRail
activePanel={panel}
hasProject={Boolean(openId)}
onSelect={setPanel}
/>
<main
data-testid="web-shell-canvas"
className="min-w-0 flex-1 overflow-y-auto bg-canvas 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 md:border-x md:border-border md:p-5"
>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<ReconnectBanner />
<div className="md:hidden">
<WebPanelMenuButton
activePanel={panel}
hasProject={Boolean(openId)}
onSelect={setPanel}
/>
</div>
{error && <WebWorkspaceError message={error} />}
<WebWorkspaceSurface
panel={panel}
projects={projects}
openId={openId}
openRoot={openRoot}
focusSprintId={focusSprintId}
onOpenProject={(id) => void openReadOnly(id)}
onCreateProject={createProject}
onRefreshProjects={() => void refresh()}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setPanel("tickets");
}}
/>
</div>
</main>
<WebSecondaryRail
projects={projects}
openId={openId}
openProject={openProject}
activePanel={panel}
onOpenProject={(id) => void openReadOnly(id)}
onRefreshProjects={() => void refresh()}
onSelectPanel={setPanel}
/>
</div>
</div>
</div>
);
}
function WebWorkspaceSurface({
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 <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
}
return (
<WebPanelBody
panel={panel}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={onViewSprintTickets}
/>
);
}
function WebWorkspaceError({ message }: { message: string }) {
return (
<Panel className="border-danger/40">
<p className="text-sm text-danger">{message}</p>
</Panel>
);
}
function WebDesktopTopBar({
activePanel,
openProject,
projectCount,
onShowProjects,
}: {
activePanel: WebProjectPanelId;
openProject: Project | null;
projectCount: number | null;
onShowProjects: () => void;
}) {
return (
<div
data-testid="web-shell-topbar"
className="hidden h-12 shrink-0 items-center justify-between gap-4 border-b border-border bg-surface px-4 md:flex"
>
<div className="flex min-w-0 items-baseline gap-2">
<span className="text-sm font-semibold text-content">
Surface {WEB_PANEL_META[activePanel].label}
</span>
<span className="min-w-0 truncate text-xs text-muted">
{openProject ? openProject.name : "Aucun projet ouvert"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
<span>
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
</span>
<Button variant="ghost" size="sm" onClick={onShowProjects}>
Gérer
</Button>
</div>
</div>
);
}
function WebPrimaryRail({
activePanel,
hasProject,
onSelect,
}: {
activePanel: WebProjectPanelId;
hasProject: boolean;
onSelect: (panel: WebProjectPanelId) => void;
}) {
return (
<nav
data-testid="web-shell-primary-rail"
aria-label="Surfaces principales"
className="hidden min-h-0 flex-col overflow-y-auto bg-surface p-2 md:flex"
>
<div className="px-2 pb-2 pt-1 text-[11px] font-semibold uppercase text-faint">
Surfaces
</div>
<div className="flex flex-col gap-1">
{WEB_PANEL_ORDER.map((id) => {
const meta = WEB_PANEL_META[id];
const disabled = id !== "projects" && !hasProject;
return (
<button
key={id}
type="button"
aria-label={`Surface ${meta.label}`}
aria-current={activePanel === id ? "page" : undefined}
disabled={disabled}
title={disabled ? "Ouvrir un projet pour utiliser ce panneau." : meta.description}
onClick={() => onSelect(id)}
className={cn(
"flex min-h-10 w-full flex-col rounded-md px-3 py-2 text-left transition-colors",
activePanel === id
? "bg-primary text-on-primary"
: "text-muted hover:bg-raised hover:text-content",
disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-muted",
)}
>
<span className="text-sm font-medium">{meta.shortLabel}</span>
<span
className={cn(
"truncate text-[11px]",
activePanel === id ? "text-on-primary/80" : "text-faint",
)}
>
{meta.placement === "primary" ? "Canvas" : "Rail secondaire"}
</span>
</button>
);
})}
</div>
</nav>
);
}
function WebSecondaryRail({
projects,
openId,
openProject,
activePanel,
onOpenProject,
onRefreshProjects,
onSelectPanel,
}: {
projects: Project[] | null;
openId: string | null;
openProject: Project | null;
activePanel: WebProjectPanelId;
onOpenProject: (projectId: string) => void;
onRefreshProjects: () => void;
onSelectPanel: (panel: WebProjectPanelId) => void;
}) {
const secondaryPanels = WEB_PANEL_ORDER.filter(
(id) => WEB_PANEL_META[id].placement === "secondary",
);
return (
<aside
data-testid="web-shell-secondary-rail"
className="hidden min-h-0 flex-col gap-3 overflow-y-auto bg-surface p-3 md:flex"
>
<section className="rounded-md border border-border bg-canvas p-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase text-faint">Projet actif</h2>
<Button variant="ghost" size="sm" onClick={() => onSelectPanel("projects")}>
Gérer
</Button>
</div>
{openProject ? (
<div className="mt-2 min-w-0">
<p className="truncate text-sm font-medium text-content">{openProject.name}</p>
<p className="mt-1 break-words text-xs text-faint">{openProject.root}</p>
</div>
) : (
<p className="mt-2 text-xs text-muted">Aucun projet ouvert.</p>
)}
</section>
<section className="rounded-md border border-border bg-canvas p-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase text-faint">Liste projets</h2>
<Button variant="ghost" size="sm" onClick={onRefreshProjects}>
Rafraîchir
</Button>
</div>
{activePanel === "projects" ? (
<p className="mt-3 text-xs text-muted">La liste complète est ouverte dans le canvas.</p>
) : projects === null ? (
<span className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted">
<Spinner size={12} /> Chargement…
</span>
) : projects.length === 0 ? (
<p className="mt-3 text-xs text-muted">Aucun projet.</p>
) : (
<ul className="mt-3 flex flex-col gap-1.5">
{projects.map((project) => (
<li key={project.id}>
<button
type="button"
aria-pressed={openId === project.id}
onClick={() => onOpenProject(project.id)}
className="flex w-full flex-col rounded-md border border-border bg-raised px-2.5 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
>
<span className="truncate text-xs font-medium text-content">{project.name}</span>
<span className="truncate text-[11px] text-faint">{project.root}</span>
</button>
</li>
))}
</ul>
)}
</section>
<section className="rounded-md border border-border bg-canvas p-3">
<h2 className="text-xs font-semibold uppercase text-faint">Secondaire</h2>
<div className="mt-2 flex flex-col gap-1">
{secondaryPanels.map((id) => {
const disabled = !openId;
return (
<button
key={id}
type="button"
aria-label={`Ouvrir ${WEB_PANEL_META[id].label}`}
disabled={disabled}
aria-current={activePanel === id ? "page" : undefined}
onClick={() => onSelectPanel(id)}
className={cn(
"rounded-md px-2.5 py-2 text-left text-xs transition-colors",
activePanel === id
? "bg-primary text-on-primary"
: "text-muted hover:bg-raised hover:text-content",
disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-muted",
)}
>
{WEB_PANEL_META[id].label}
</button>
);
})}
</div>
</section>
</aside>
);
}
/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
function WebPanelMenuButton({
activePanel,