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(); });