From ee2ee037f96d2429d97e948f36e1901b16a0a851 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 01:03:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20desktop=20parity=20shell=20?= =?UTF-8?q?=E2=80=94=20#203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/web/WebMenus.test.tsx | 23 +- frontend/src/features/web/WebMobile.test.tsx | 3 +- frontend/src/features/web/WebWorkspace.tsx | 461 +++++++++++++++++-- 3 files changed, 437 insertions(+), 50 deletions(-) diff --git a/frontend/src/features/web/WebMenus.test.tsx b/frontend/src/features/web/WebMenus.test.tsx index b04b45f2..4f2cbe6b 100644 --- a/frontend/src/features/web/WebMenus.test.tsx +++ b/frontend/src/features/web/WebMenus.test.tsx @@ -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 () => { diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx index eeeccb18..266611fb 100644 --- a/frontend/src/features/web/WebMobile.test.tsx +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -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 () => { diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index ed70ff31..7a76c1bb 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -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 = { - 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 = { + 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 = Object.fromEntries( + WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]), +) as Record; + export function WebWorkspace() { const { project } = useGateways(); const [projects, setProjects] = useState(null); @@ -106,6 +187,10 @@ export function WebWorkspace() { const [focusSprintId, setFocusSprintId] = useState(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 (
- - - - - {error && ( - -

{error}

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

Ouvrir un projet pour utiliser ce panneau.

- ) : ( - { - setFocusSprintId(sprintId); - setPanel("tickets"); - }} - /> - )} + +
+ + +
+
+ +
+ +
+ {error && } + void openReadOnly(id)} + onCreateProject={createProject} + onRefreshProjects={() => void refresh()} + onViewSprintTickets={(sprintId) => { + setFocusSprintId(sprintId); + setPanel("tickets"); + }} + /> +
+
+ + void openReadOnly(id)} + onRefreshProjects={() => void refresh()} + onSelectPanel={setPanel} + /> +
+
); } +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; + onRefreshProjects: () => void; + onViewSprintTickets: (sprintId: string) => void; +}) { + if (panel === "projects") { + return ( + + ); + } + + if (!openId) { + return

Ouvrir un projet pour utiliser ce panneau.

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

{message}

+
+ ); +} + +function WebDesktopTopBar({ + activePanel, + openProject, + projectCount, + onShowProjects, +}: { + activePanel: WebProjectPanelId; + openProject: Project | null; + projectCount: number | null; + onShowProjects: () => void; +}) { + return ( +
+
+ + Surface {WEB_PANEL_META[activePanel].label} + + + {openProject ? openProject.name : "Aucun projet ouvert"} + +
+
+ + {projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`} + + +
+
+ ); +} + +function WebPrimaryRail({ + activePanel, + hasProject, + onSelect, +}: { + activePanel: WebProjectPanelId; + hasProject: boolean; + onSelect: (panel: WebProjectPanelId) => void; +}) { + return ( + + ); +} + +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 ( + + ); +} + /** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */ function WebPanelMenuButton({ activePanel,