Merge feature/ticket90-web-missing-menus into develop

Ajoute les menus Panneaux/Paramètres manquants à la version web
(#90), avec élargissement de l'allowlist /api/invoke côté
web-server pour router les commandes correspondantes.

QA : 895/895 tests frontend, tsc propre, tests backend
web_invoke_routes verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:16:55 +02:00
8 changed files with 2038 additions and 184 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,53 @@
/** /**
* Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`) * Web client root — ticket #13, lot F2, extended by #90 (missing menus).
* only when the HTTP transport is selected (`VITE_TRANSPORT="http"`). * 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}; * Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's * paired ⇒ the read-only {@link WebWorkspace}, or a Settings section opened from
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing * the "Paramètres" menu (#90). It subscribes to the session's `onUnauthorized`
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the * signal so a `401` from any `/api/invoke` (expired/missing cookie) drops back to
* server session (`POST /api/logout`), tears down the live WS singleton, and * pairing automatically. "Se déconnecter" (F6) revokes the server session
* returns to pairing. * (`POST /api/logout`), tears down the live WS singleton, and returns to pairing.
* *
* "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component * "Paramètres" (#90) replaces the former single "Appareils" header toggle with a
* the desktop shows under `Paramètres → Appareils`. It is what makes a headless * menu of sections: **Profils IA** (mounts the transport-neutral
* install usable: generating a pairing code from an already-paired phone instead * {@link ProfilesSettings}), **Appareils** (#77, mounts the shared
* of restarting the server with `--new-code`. * {@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 { Button } from "@/shared";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { DevicesScreen } from "@/features/devices"; import { DevicesScreen } from "@/features/devices";
import { ProfilesSettings } from "@/features/first-run";
import { PairingScreen } from "./PairingScreen"; import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace"; import { WebWorkspace } from "./WebWorkspace";
import { WebMenuSheet } from "./WebMenuSheet";
interface WebAppProps { interface WebAppProps {
/** Injectable session (tests); defaults to the shared singleton. */ /** Injectable session (tests); defaults to the shared singleton. */
session?: WebSession; session?: WebSession;
} }
type WebSettingsSection = "aiProfiles" | "devices";
const SETTINGS_SECTION_LABEL: Record<WebSettingsSection, string> = {
aiProfiles: "Profils IA",
devices: "Appareils",
};
export function WebApp({ session }: WebAppProps = {}) { export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession(); const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired()); const [paired, setPaired] = useState(() => webSession.isPaired());
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
const [showDevices, setShowDevices] = useState(false); const [settingsSection, setSettingsSection] = useState<WebSettingsSection | null>(null);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing. The live // 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 // path for a *suffered* revocation (#77): another device revoked us, the
// server rejects the next call, and we land back on pairing. // server rejects the next call, and we land back on pairing.
return webSession.onUnauthorized(() => { return webSession.onUnauthorized(() => {
setShowDevices(false); setSettingsSection(null);
setSettingsMenuOpen(false);
setPaired(false); setPaired(false);
}); });
}, [webSession]); }, [webSession]);
@ -55,7 +71,8 @@ export function WebApp({ session }: WebAppProps = {}) {
} finally { } finally {
disconnectWebLive(); disconnectWebLive();
setSigningOut(false); setSigningOut(false);
setShowDevices(false); setSettingsSection(null);
setSettingsMenuOpen(false);
setPaired(false); setPaired(false);
} }
} }
@ -69,7 +86,8 @@ export function WebApp({ session }: WebAppProps = {}) {
const onSessionEnded = useCallback(() => { const onSessionEnded = useCallback(() => {
webSession.forget(); webSession.forget();
disconnectWebLive(); disconnectWebLive();
setShowDevices(false); setSettingsSection(null);
setSettingsMenuOpen(false);
setPaired(false); setPaired(false);
}, [webSession]); }, [webSession]);
@ -90,10 +108,10 @@ export function WebApp({ session }: WebAppProps = {}) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-pressed={showDevices} aria-pressed={settingsMenuOpen}
onClick={() => setShowDevices((v) => !v)} onClick={() => setSettingsMenuOpen(true)}
> >
{showDevices ? "Projets" : "Appareils"} Paramètres
</Button> </Button>
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}> <Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter Se déconnecter
@ -105,14 +123,85 @@ export function WebApp({ session }: WebAppProps = {}) {
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{!paired ? ( {!paired ? (
<PairingScreen session={webSession} onPaired={() => setPaired(true)} /> <PairingScreen session={webSession} onPaired={() => setPaired(true)} />
) : showDevices ? ( ) : settingsSection === "aiProfiles" ? (
<div className="h-full 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"> <SettingsSectionScreen
title={SETTINGS_SECTION_LABEL.aiProfiles}
onBack={() => setSettingsSection(null)}
>
<ProfilesSettings />
</SettingsSectionScreen>
) : settingsSection === "devices" ? (
<SettingsSectionScreen
title={SETTINGS_SECTION_LABEL.devices}
onBack={() => setSettingsSection(null)}
>
<DevicesScreen onSessionEnded={onSessionEnded} /> <DevicesScreen onSessionEnded={onSessionEnded} />
</div> </SettingsSectionScreen>
) : ( ) : (
<WebWorkspace /> <WebWorkspace />
)} )}
</div> </div>
{settingsMenuOpen && (
<WebMenuSheet
title="Paramètres"
onClose={() => setSettingsMenuOpen(false)}
entries={[
{
id: "aiProfiles",
label: SETTINGS_SECTION_LABEL.aiProfiles,
active: settingsSection === "aiProfiles",
onSelect: () => {
setSettingsSection("aiProfiles");
setSettingsMenuOpen(false);
},
},
{
id: "devices",
label: SETTINGS_SECTION_LABEL.devices,
active: settingsSection === "devices",
onSelect: () => {
setSettingsSection("devices");
setSettingsMenuOpen(false);
},
},
{
id: "deployment",
label: "Déploiement",
active: false,
disabled: true,
secondaryLabel: "Desktop uniquement",
hint: "Desktop uniquement",
onSelect: () => {},
},
]}
/>
)}
</div>
);
}
/** A Settings section takes over the main area, with a "← Retour" affordance. */
function SettingsSectionScreen({
title,
onBack,
children,
}: {
title: string;
onBack: () => void;
children: ReactNode;
}) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))]">
<Button variant="ghost" size="sm" onClick={onBack}>
Retour
</Button>
<span className="text-sm font-medium text-content">{title}</span>
</div>
<div className="flex-1 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">
<div className="mx-auto w-full max-w-xl">{children}</div>
</div>
</div> </div>
); );
} }

View File

@ -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<HTMLButtonElement>(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 (
<div
className="fixed inset-0 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ zIndex: zIndex.floatingWindowNested }}
>
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
<span className="min-w-0 truncate text-sm font-medium text-content">{title}</span>
<Button ref={closeRef} size="sm" variant="ghost" onClick={onClose}>
Fermer
</Button>
</header>
{note && (
<p className="shrink-0 border-b border-border px-4 py-2 text-xs text-muted">{note}</p>
)}
<ul className="min-h-0 flex-1 overflow-auto px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
{entries.map((entry) => (
<li key={entry.id}>
<button
type="button"
disabled={entry.disabled}
title={entry.hint}
aria-current={entry.active ? "true" : undefined}
onClick={() => {
if (entry.disabled) return;
entry.onSelect();
}}
className={cn(
"flex min-h-[44px] w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors",
"hover:bg-raised focus:bg-raised focus:outline-none disabled:opacity-50 disabled:hover:bg-transparent",
entry.active && "bg-raised font-medium text-content",
)}
>
<span aria-hidden="true" className="w-3 shrink-0 text-primary">
{entry.active ? "●" : ""}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
{entry.secondaryLabel && (
<span className="shrink-0 text-xs text-muted">{entry.secondaryLabel}</span>
)}
</button>
</li>
))}
</ul>
</div>
);
}

View File

@ -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<string, string>();
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<Gateways> {
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(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
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();
});
});

View File

@ -100,6 +100,19 @@ describe("web client on a phone viewport", () => {
expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); 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 () => { it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => {
await renderPairedWebApp(); await renderPairedWebApp();

View File

@ -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<Project>;
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<string | null>(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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
<Button variant="ghost" size="sm" onClick={onRefresh}>
Rafraîchir
</Button>
</div>
<Panel title="Nouveau projet">
<form onSubmit={(e) => void submit(e)} className="flex flex-col gap-2">
<Input
aria-label="Nom du projet"
placeholder="Nom du projet"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
aria-label="Chemin du projet"
placeholder="/chemin/absolu/du/projet"
value={root}
onChange={(e) => setRoot(e.target.value)}
/>
<p className="text-xs text-muted">
Saisir un chemin absolu accessible depuis le serveur IdeA.
</p>
{createError && (
<p role="alert" className="text-xs text-danger">
{createError}
</p>
)}
<Button type="submit" variant="primary" disabled={!canCreate} loading={busy}>
Créer le projet
</Button>
</form>
</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>
) : (
<ul className="flex flex-col gap-2" data-testid="web-project-list">
{projects.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onOpen(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>
)}
</div>
);
}

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** * After pairing: list/create projects (web variant — no native folder browse,
* work-state — agents (live/idle/busy), background tasks (with cancel/retry) and * see {@link WebProjectsPanel}), open one read-only, and route the main surface
* per-agent inbox — updated in real time. The live mechanism is the desktop's own * through the **"Panneaux" menu** (#90) across the transport-neutral panels also
* transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on * used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
* relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect} * skills/permissions/memory/git/projects) plus the web-specific live work-state
* re-synchronises after a WS outage. Background cancel/retry go through the * view below. Selecting a panel replaces the main surface — never a floating
* {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can * window or dock (desktop-only chrome, out of scope for web, per Architect).
* be opened into a live cell (F4). No component touches `@tauri-apps/api`.
* *
* Read-only allowlist used (B4/B7): `list_projects`, `open_project`, * The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
* `get_project_work_state`, plus the background `cancel`/`retry` commands and the * implementation rather than the desktop `ProjectWorkStatePanel`: that component's
* `event.domain` live stream. * "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"; import { useCallback, useEffect, useState } from "react";
@ -25,12 +29,22 @@ import type {
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; 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 { Button, Panel, Spinner, cn } from "@/shared";
import { WebAgentCell } from "./WebAgentCell"; import { WebAgentCell } from "./WebAgentCell";
import { useLiveReconnect } from "./useLiveReconnect"; import { useLiveReconnect } from "./useLiveReconnect";
import { useLiveConnectionState } from "./useLiveConnectionState"; import { useLiveConnectionState } from "./useLiveConnectionState";
import { WebTicketsView } from "./tickets/WebTicketsView"; import { WebTicketsView } from "./tickets/WebTicketsView";
import { WebSprintsView } from "./tickets/WebSprintsView"; import { WebSprintsView } from "./tickets/WebSprintsView";
import { WebProjectsPanel } from "./WebProjectsPanel";
import { WebMenuSheet } from "./WebMenuSheet";
function describe(e: unknown): string { function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) { if (e && typeof e === "object" && "message" in e) {
@ -39,11 +53,57 @@ function describe(e: unknown): string {
return String(e); 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() { export function WebWorkspace() {
const { project } = useGateways(); const { project } = useGateways();
const [projects, setProjects] = useState<Project[] | null>(null); const [projects, setProjects] = useState<Project[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = 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; 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. // its work-state. No layout/PTY is mounted here.
await project.openProject(projectId); await project.openProject(projectId);
setOpenId(projectId); setOpenId(projectId);
setPanel("work");
} catch (e) { } catch (e) {
setError(describe(e)); setError(describe(e));
} }
@ -76,18 +137,29 @@ export function WebWorkspace() {
[project], [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 ( return (
<div <div
data-testid="web-workspace" 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" 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 /> <ReconnectBanner />
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Projets</h2> <WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
Rafraîchir
</Button>
</div>
{error && ( {error && (
<Panel className="border-danger/40"> <Panel className="border-danger/40">
@ -95,134 +167,120 @@ export function WebWorkspace() {
</Panel> </Panel>
)} )}
{projects === null ? ( {panel === "projects" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted"> <WebProjectsPanel
<Spinner size={12} /> Chargement des projets projects={projects}
</span> openId={openId}
) : projects.length === 0 ? ( onOpen={(id) => void openReadOnly(id)}
<p className="text-sm text-muted">Aucun projet.</p> 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"> <WebPanelBody
{projects.map((p) => ( panel={panel}
<li key={p.id}> projectId={openId}
<button root={openRoot}
type="button" focusSprintId={focusSprintId}
onClick={() => void openReadOnly(p.id)} onViewSprintTickets={(sprintId) => {
aria-pressed={openId === p.id} setFocusSprintId(sprintId);
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" setPanel("tickets");
> }}
<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} />
)} )}
</div> </div>
); );
} }
type ProjectTab = "live" | "tickets" | "sprints"; /** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
function WebPanelMenuButton({
const PROJECT_TABS: { id: ProjectTab; label: string }[] = [ activePanel,
{ id: "live", label: "Live" }, hasProject,
{ id: "tickets", label: "Tickets" }, onSelect,
{ id: "sprints", label: "Sprints" }, }: {
]; activePanel: WebProjectPanelId;
hasProject: boolean;
/** onSelect: (panel: WebProjectPanelId) => void;
* Project navigation for the web workspace (ticket #86): `Live` / `Tickets` / }) {
* `Sprints`, a compact tablist above the project surfaces — never the desktop const [open, setOpen] = useState(false);
* 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);
return ( return (
<div className="mt-2 flex flex-col gap-3"> <div className="flex items-center justify-between">
<div <Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
role="tablist" Panneaux : {WEB_PANEL_LABEL[activePanel]}
aria-label="Navigation du projet" </Button>
className="flex gap-1 overflow-x-auto border-b border-border" {open && (
> <WebMenuSheet
{PROJECT_TABS.map((t) => ( title="Panneaux"
<button note={
key={t.id} !hasProject ? "Ouvrir un projet pour utiliser ce panneau." : undefined
type="button" }
role="tab" onClose={() => setOpen(false)}
id={`web-project-tab-${t.id}`} entries={WEB_PANEL_ORDER.map((id) => {
aria-selected={tab === t.id} const disabled = id !== "projects" && !hasProject;
aria-controls={`web-project-tabpanel-${t.id}`} return {
tabIndex={tab === t.id ? 0 : -1} id,
onClick={() => setTab(t.id)} label: WEB_PANEL_LABEL[id],
onKeyDown={(e) => { active: id === activePanel,
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; disabled,
e.preventDefault(); hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined,
const i = PROJECT_TABS.findIndex((x) => x.id === tab); onSelect: () => {
const next = onSelect(id);
e.key === "ArrowRight" setOpen(false);
? (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> </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é" * 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 * notice into xterm, but live-only surfaces (no terminal open) had no visible

View File

@ -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); renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA")); fireEvent.click(await screen.findByText("IdeA"));
await screen.findByRole("tablist", { name: "Navigation du projet" }); fireEvent.click(await screen.findByRole("button", { name: /^Panneaux :/ }));
fireEvent.click(screen.getByRole("tab", { name: tabName })); fireEvent.click(await screen.findByRole("button", { name: panelLabel }));
} }
describe("WebWorkspace — project tabs (ticket #86)", () => { 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(); const { gateways } = await setup();
renderWorkspace(gateways); renderWorkspace(gateways);
fireEvent.click(await screen.findByText("IdeA")); fireEvent.click(await screen.findByText("IdeA"));
const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" }); expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy();
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");
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(); 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(); expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy();
}); });