From 30f6415d5156e2a4991776b804a245510b190f35 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 18:33:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20ajoute=20les=20menus=20Pannea?= =?UTF-8?q?ux/Param=C3=A8tres=20=C3=A0=20la=20version=20web=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/features/web/WebApp.tsx | 133 +++++-- frontend/src/features/web/WebMenuSheet.tsx | 101 ++++++ frontend/src/features/web/WebMenus.test.tsx | 158 +++++++++ frontend/src/features/web/WebMobile.test.tsx | 13 + .../src/features/web/WebProjectsPanel.tsx | 124 +++++++ frontend/src/features/web/WebWorkspace.tsx | 328 +++++++++++------- .../web/tickets/WebTicketsSprints.test.tsx | 20 +- 7 files changed, 710 insertions(+), 167 deletions(-) create mode 100644 frontend/src/features/web/WebMenuSheet.tsx create mode 100644 frontend/src/features/web/WebMenus.test.tsx create mode 100644 frontend/src/features/web/WebProjectsPanel.tsx diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index d9cb050..6a2d7f9 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -1,38 +1,53 @@ /** - * Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`) - * only when the HTTP transport is selected (`VITE_TRANSPORT="http"`). + * Web client root — ticket #13, lot F2, extended by #90 (missing menus). + * Mounted (instead of the desktop `App`) only when the HTTP transport is + * selected (`VITE_TRANSPORT="http"`). * * Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen}; - * paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's - * `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing - * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the - * server session (`POST /api/logout`), tears down the live WS singleton, and - * returns to pairing. + * paired ⇒ the read-only {@link WebWorkspace}, or a Settings section opened from + * the "Paramètres" menu (#90). It subscribes to the session's `onUnauthorized` + * signal so a `401` from any `/api/invoke` (expired/missing cookie) drops back to + * pairing automatically. "Se déconnecter" (F6) revokes the server session + * (`POST /api/logout`), tears down the live WS singleton, and returns to pairing. * - * "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component - * the desktop shows under `Paramètres → Appareils`. It is what makes a headless - * install usable: generating a pairing code from an already-paired phone instead - * of restarting the server with `--new-code`. + * "Paramètres" (#90) replaces the former single "Appareils" header toggle with a + * menu of sections: **Profils IA** (mounts the transport-neutral + * {@link ProfilesSettings}), **Appareils** (#77, mounts the shared + * {@link DevicesScreen} — what makes a headless install usable: generating a + * pairing code from an already-paired phone instead of restarting the server with + * `--new-code`), and **Déploiement**, disabled — the embedded server is + * desktop-only (a web client is *served by* it, so it must not reconfigure or + * stop it, see `adapters/http/unsupported.ts`). */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Button } from "@/shared"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { DevicesScreen } from "@/features/devices"; +import { ProfilesSettings } from "@/features/first-run"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; +import { WebMenuSheet } from "./WebMenuSheet"; interface WebAppProps { /** Injectable session (tests); defaults to the shared singleton. */ session?: WebSession; } +type WebSettingsSection = "aiProfiles" | "devices"; + +const SETTINGS_SECTION_LABEL: Record = { + aiProfiles: "Profils IA", + devices: "Appareils", +}; + export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); const [signingOut, setSigningOut] = useState(false); - const [showDevices, setShowDevices] = useState(false); + const [settingsSection, setSettingsSection] = useState(null); + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); useEffect(() => { // A 401 anywhere clears the flag and fires this: return to pairing. The live @@ -40,7 +55,8 @@ export function WebApp({ session }: WebAppProps = {}) { // path for a *suffered* revocation (#77): another device revoked us, the // server rejects the next call, and we land back on pairing. return webSession.onUnauthorized(() => { - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); }); }, [webSession]); @@ -55,7 +71,8 @@ export function WebApp({ session }: WebAppProps = {}) { } finally { disconnectWebLive(); setSigningOut(false); - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); } } @@ -69,7 +86,8 @@ export function WebApp({ session }: WebAppProps = {}) { const onSessionEnded = useCallback(() => { webSession.forget(); disconnectWebLive(); - setShowDevices(false); + setSettingsSection(null); + setSettingsMenuOpen(false); setPaired(false); }, [webSession]); @@ -90,10 +108,10 @@ export function WebApp({ session }: WebAppProps = {}) { + {title} + +
+
{children}
+
); } diff --git a/frontend/src/features/web/WebMenuSheet.tsx b/frontend/src/features/web/WebMenuSheet.tsx new file mode 100644 index 0000000..1837243 --- /dev/null +++ b/frontend/src/features/web/WebMenuSheet.tsx @@ -0,0 +1,101 @@ +/** + * Full-screen navigation sheet for the web client (ticket #90): a mobile-first + * menu — never a desktop dropdown — reused by both the "Panneaux" menu + * ({@link WebWorkspace}) and the "Paramètres" menu ({@link WebApp}). Same shell + * as {@link WebTicketPickerSheet}: fixed header with a close action, an optional + * shared note, and a scrollable list of entries. The active entry is marked with + * "●"; a disabled entry stays visible (never removed) so its reason is + * discoverable, via `hint` (title tooltip) and/or `secondaryLabel` (always-visible + * text — the only way to communicate "why" on a touch device with no hover). + */ + +import { useEffect, useRef } from "react"; + +import { Button, cn, zIndex } from "@/shared"; + +export interface WebMenuEntry { + id: string; + label: string; + active: boolean; + disabled?: boolean; + /** Tooltip shown on hover/focus (desktop browsers); not relied on for touch. */ + hint?: string; + /** Always-visible secondary text, e.g. "Desktop uniquement". */ + secondaryLabel?: string; + onSelect: () => void; +} + +export interface WebMenuSheetProps { + title: string; + entries: WebMenuEntry[]; + /** Shared note shown once above the list (e.g. why some entries are disabled). */ + note?: string; + onClose: () => void; +} + +export function WebMenuSheet({ title, entries, note, onClose }: WebMenuSheetProps) { + const closeRef = useRef(null); + + useEffect(() => { + closeRef.current?.focus(); + }, []); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
+
+ {title} + +
+ + {note && ( +

{note}

+ )} + +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/features/web/WebMenus.test.tsx b/frontend/src/features/web/WebMenus.test.tsx new file mode 100644 index 0000000..b04b45f --- /dev/null +++ b/frontend/src/features/web/WebMenus.test.tsx @@ -0,0 +1,158 @@ +/** + * Ticket #90 — the web "Panneaux" (WebWorkspace) and "Paramètres" (WebApp) menus. + * + * Both replace the previous ad-hoc navigation (a 3-tab tablist, a single + * "Appareils" header toggle) with a full-screen {@link WebMenuSheet}: this pins + * the new contract — entry list, active marker, disabled-with-reason state, and + * that selecting an entry routes the main surface without ever crashing on a + * desktop-only action. + */ +import { describe, it, expect } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { createMockGateways } from "@/adapters/mock"; +import { WebSession } from "@/adapters/http"; +import type { FetchLike } from "@/adapters/http/httpInvoker"; +import type { FlagStore } from "@/adapters/http/webSession"; +import { WebApp } from "./WebApp"; + +function memStore(): FlagStore { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +const okFetch: FetchLike = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +async function seededGateways(): Promise { + const gateways = createMockGateways(); + await gateways.project.createProject("Demo", "/srv/demo"); + return gateways; +} + +function renderPairedWebApp(gateways: Gateways) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + return render( + + + , + ); +} + +describe("WebWorkspace « Panneaux » menu (#90)", () => { + it("only enables Projets while no project is open, with a help note", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Projets" })); + expect( + await screen.findByText("Ouvrir un projet pour utiliser ce panneau."), + ).toBeTruthy(); + + const contextEntry = screen.getByRole("button", { name: "Contexte projet" }); + expect(contextEntry).toHaveProperty("disabled", true); + const projectsEntry = screen.getByRole("button", { name: "Projets" }); + expect(projectsEntry).toHaveProperty("disabled", false); + + // A disabled entry does nothing. + fireEvent.click(contextEntry); + expect(screen.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 () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByText("Demo")); + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + const agentsEntry = await screen.findByRole("button", { name: "Agents" }); + expect(agentsEntry).toHaveProperty("disabled", false); + fireEvent.click(agentsEntry); + + expect(await screen.findByRole("button", { name: "Panneaux : Agents" })).toBeTruthy(); + }); + + it("closes on Escape without changing the panel", async () => { + renderPairedWebApp(await seededGateways()); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByRole("button", { name: "Panneaux : Travail" }); + + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + await screen.findByRole("dialog", { name: "Panneaux" }); + fireEvent.keyDown(window, { key: "Escape" }); + + expect(screen.queryByRole("dialog", { name: "Panneaux" })).toBeNull(); + expect(screen.getByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + }); +}); + +describe("WebProjectsPanel — web project creation (#90)", () => { + it("creates a project via a manual path and opens it", async () => { + renderPairedWebApp(await seededGateways()); + + await screen.findByText("Demo"); + fireEvent.change(screen.getByLabelText("Nom du projet"), { + target: { value: "Nouveau" }, + }); + fireEvent.change(screen.getByLabelText("Chemin du projet"), { + target: { value: "/srv/nouveau" }, + }); + expect( + screen.getByText("Saisir un chemin absolu accessible depuis le serveur IdeA."), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Créer le projet" })); + + // Creating opens it — routes straight to the Travail panel, like opening + // an existing project from the list. + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); + }); +}); + +describe("WebApp « Paramètres » menu (#90)", () => { + it("opens Profils IA and returns to the workspace via « ← Retour »", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + fireEvent.click(await screen.findByRole("button", { name: "Profils IA" })); + + expect(await screen.findByRole("heading", { name: "Profils IA" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^Panneaux :/ })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "← Retour" })); + expect(await screen.findByRole("button", { name: /^Panneaux :/ })).toBeTruthy(); + }); + + it("opens Appareils from the settings menu", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + fireEvent.click(await screen.findByRole("button", { name: "Appareils" })); + + expect(await screen.findByRole("button", { name: "← Retour" })).toBeTruthy(); + }); + + it("shows Déploiement as disabled, labelled desktop-only, and does nothing on click", async () => { + renderPairedWebApp(await seededGateways()); + + fireEvent.click(await screen.findByRole("button", { name: "Paramètres" })); + const deploymentEntry = await screen.findByRole("button", { name: /Déploiement/ }); + expect(deploymentEntry.textContent).toContain("Desktop uniquement"); + expect(deploymentEntry).toHaveProperty("disabled", true); + + fireEvent.click(deploymentEntry); + // Still on the menu — no navigation happened. + expect(screen.getByRole("dialog", { name: "Paramètres" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx index 40ed361..eeeccb1 100644 --- a/frontend/src/features/web/WebMobile.test.tsx +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -100,6 +100,19 @@ describe("web client on a phone viewport", () => { expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); }); + it("opens the Panneaux menu as a full-screen sheet at 360px (no fixed-width overflow)", async () => { + await renderPairedWebApp(); + + fireEvent.click(await screen.findByText("Demo")); + fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Travail" })); + + const sheet = await screen.findByRole("dialog", { name: "Panneaux" }); + // Full-viewport overlay (`fixed inset-0`), not a fixed-width dropdown that + // could overflow a 360px screen. + expect(sheet.className).toContain("fixed"); + expect(sheet.className).toContain("inset-0"); + }); + it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => { await renderPairedWebApp(); diff --git a/frontend/src/features/web/WebProjectsPanel.tsx b/frontend/src/features/web/WebProjectsPanel.tsx new file mode 100644 index 0000000..894432e --- /dev/null +++ b/frontend/src/features/web/WebProjectsPanel.tsx @@ -0,0 +1,124 @@ +/** + * `WebProjectsPanel` — the "Projects" panel, web variant (ticket #90). + * + * The desktop `ProjectsView` project manager depends on `SystemGateway.pickFolder` + * (a native folder-browse dialog), which is desktop-only (see + * `adapters/http/unsupported.ts`). The web client cannot reuse it as-is: project + * creation here takes the root as a manually typed absolute path, resolved + * server-side (the machine running the IdeA server, not the browsing device). + */ + +import { useState } from "react"; + +import type { GatewayError, Project } from "@/domain"; +import { Button, Input, Panel, Spinner } from "@/shared"; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export interface WebProjectsPanelProps { + projects: Project[] | null; + openId: string | null; + onOpen: (projectId: string) => void; + onCreate: (name: string, root: string) => Promise; + onRefresh: () => void; +} + +export function WebProjectsPanel({ + projects, + openId, + onOpen, + onCreate, + onRefresh, +}: WebProjectsPanelProps) { + const [name, setName] = useState(""); + const [root, setRoot] = useState(""); + const [busy, setBusy] = useState(false); + const [createError, setCreateError] = useState(null); + + const canCreate = name.trim().length > 0 && root.trim().length > 0 && !busy; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!canCreate) return; + setBusy(true); + setCreateError(null); + try { + const created = await onCreate(name.trim(), root.trim()); + setName(""); + setRoot(""); + onOpen(created.id); + } catch (e) { + setCreateError(describe(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Projets

+ +
+ + +
void submit(e)} className="flex flex-col gap-2"> + setName(e.target.value)} + /> + setRoot(e.target.value)} + /> +

+ Saisir un chemin absolu accessible depuis le serveur IdeA. +

+ {createError && ( +

+ {createError} +

+ )} + +
+
+ + {projects === null ? ( + + Chargement des projets… + + ) : projects.length === 0 ? ( +

Aucun projet.

+ ) : ( +
    + {projects.map((p) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 185b6b4..9bc3ed6 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -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 = { + 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(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 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 => { + setError(null); + try { + const created = await project.createProject(name, root); + await refresh(); + return created; + } catch (e) { + setError(describe(e)); + throw e; + } + }, + [project, refresh], + ); + return (
-
-

Projets

- -
+ + {error && ( @@ -95,134 +167,120 @@ export function WebWorkspace() { )} - {projects === null ? ( - - Chargement des projets… - - ) : projects.length === 0 ? ( -

Aucun projet.

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

Ouvrir un projet pour utiliser ce panneau.

) : ( -
    - {projects.map((p) => ( -
  • - -
  • - ))} -
- )} - - {openId && ( - + { + setFocusSprintId(sprintId); + setPanel("tickets"); + }} + /> )}
); } -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("live"); - // Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab. - const [focusSprintId, setFocusSprintId] = useState(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 ( -
-
- {PROJECT_TABS.map((t) => ( - - ))} -
- - - - +
+ + {open && ( + 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); + }, + }; + })} + /> + )}
); } +/** Renders the requested panel's body for the opened project. */ +function WebPanelBody({ + panel, + projectId, + root, + focusSprintId, + onViewSprintTickets, +}: { + panel: Exclude; + projectId: string; + root: string | null; + focusSprintId: string | null; + onViewSprintTickets: (sprintId: string) => void; +}) { + 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 ; + } +} + /** * 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 diff --git a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx index dd31a18..879d027 100644 --- a/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx +++ b/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx @@ -74,28 +74,28 @@ function renderWorkspace(gateways: Gateways) { ); } -async function openProjectAndTab(gateways: Gateways, tabName: string) { +/** Opens the panel named `panelLabel` ("Tickets"/"Sprints"/…) via the "Panneaux" menu. */ +async function openProjectAndTab(gateways: Gateways, panelLabel: string) { renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); - await screen.findByRole("tablist", { name: "Navigation du projet" }); - fireEvent.click(screen.getByRole("tab", { name: tabName })); + fireEvent.click(await screen.findByRole("button", { name: /^Panneaux :/ })); + fireEvent.click(await screen.findByRole("button", { name: panelLabel })); } describe("WebWorkspace — project tabs (ticket #86)", () => { - it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => { + it("defaults to the Travail panel and lets the user switch to Tickets/Sprints via Panneaux", async () => { const { gateways } = await setup(); renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); - const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" }); - const tabs = within(tablist).getAllByRole("tab"); - expect(tabs.map((t) => t.textContent)).toEqual(["Live", "Tickets", "Sprints"]); - expect(screen.getByRole("tab", { name: "Live" }).getAttribute("aria-selected")).toBe("true"); + expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Tickets" })); + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); + fireEvent.click(await screen.findByRole("button", { name: "Tickets" })); expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Sprints" })); + fireEvent.click(screen.getByRole("button", { name: "Panneaux : Tickets" })); + fireEvent.click(await screen.findByRole("button", { name: "Sprints" })); expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy(); });