Merge feature/ticket26-ui-rework-menus into develop

Refonte menus & fenêtres (#26) : menu unique Panneaux, onglets projet
avec bouton +, sous-menus flyout. QA vert (tsc + 616 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:08:52 +02:00
7 changed files with 305 additions and 217 deletions

View File

@ -1,9 +1,13 @@
/** /**
* `ProjectTabs` — the project tab bar rendered below the app header. * `ProjectTabs` — the project tab bar rendered below the app header.
* *
* Shows one tab per open project with a close control, and a "+" button to * Shows one tab per open project with a close control, and a "+" button to the
* return to the launcher. Purely presentational: all state lives in the * right that opens the **Projects** panel (floating) — the single entry point
* parent (ProjectsView via useProjects). * for creating a project or switching to a known one (#26). Purely
* presentational: all state lives in the parent (ProjectsView via useProjects).
*
* The bar is always present (even with no open project) so the "+" stays
* reachable; the empty case shows a muted placeholder next to it.
*/ */
import type { TabItem } from "@/shared"; import type { TabItem } from "@/shared";
@ -14,9 +18,10 @@ export interface ProjectTabsProps {
activeTabId: string | null; activeTabId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onClose: (id: string) => void; onClose: (id: string) => void;
/** Whether the launcher overlay is currently visible (no active tab). */ /** Whether the Projects panel is currently open (in any placement). */
showingLauncher: boolean; projectsPanelOpen: boolean;
onShowLauncher: () => void; /** Open the Projects panel (floating) — create/switch project. */
onOpenProjectsPanel: () => void;
className?: string; className?: string;
} }
@ -25,19 +30,20 @@ export function ProjectTabs({
activeTabId, activeTabId,
onSelect, onSelect,
onClose, onClose,
showingLauncher, projectsPanelOpen,
onShowLauncher, onOpenProjectsPanel,
className, className,
}: ProjectTabsProps) { }: ProjectTabsProps) {
if (items.length === 0) return null;
return ( return (
<div <div
className={cn( className={cn(
"flex items-center gap-1 border-b border-border bg-surface px-2 py-1", "flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1",
className, className,
)} )}
> >
{items.length === 0 ? (
<p className="flex-1 px-2 text-sm text-muted">No open tabs.</p>
) : (
<Tabs <Tabs
items={items} items={items}
value={activeTabId} value={activeTabId}
@ -45,11 +51,12 @@ export function ProjectTabs({
onClose={onClose} onClose={onClose}
className="flex-1" className="flex-1"
/> />
)}
<Button <Button
size="sm" size="sm"
variant={showingLauncher ? "secondary" : "ghost"} variant={projectsPanelOpen ? "secondary" : "ghost"}
onClick={onShowLauncher} onClick={onOpenProjectsPanel}
aria-label="open project launcher" aria-label="open projects panel"
className="shrink-0" className="shrink-0"
> >
+ +

View File

@ -49,23 +49,27 @@ async function renderWithProject() {
return { windowGateway, projectId: created.id }; return { windowGateway, projectId: created.id };
} }
function openMenuItem(menu: string, item: string) { /**
fireEvent.click(screen.getByRole("button", { name: menu })); * Picks a placement for a panel from the single « Panneaux » menu (#26): menu
fireEvent.click(screen.getByRole("button", { name: item })); * panel entry (opens its placement submenu) → the placement leaf ("Flottant",
* "Ancré à gauche", "Fenêtre détachée", …).
*/
function pickPlacement(panelTitle: string, placementLabel: string) {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
fireEvent.click(screen.getByRole("button", { name: placementLabel }));
} }
describe("ProjectsView detach to OS window (#23)", () => { describe("ProjectsView detach to OS window (#23)", () => {
it("Window menu detaches a view: opens the OS window and marks the slot detached", async () => { it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => {
const { windowGateway, projectId } = await renderWithProject(); const { windowGateway, projectId } = await renderWithProject();
// A docked view is visible in the main window first… // A docked view is visible in the main window first…
openMenuItem("View", "Git"); pickPlacement("Git", "Ancré à gauche");
const dialog = await screen.findByRole("dialog", { name: "Git" });
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
await screen.findByRole("complementary", { name: "left dock" }); await screen.findByRole("complementary", { name: "left dock" });
// …then « Git → nouvelle fenêtre » pops it out. // …then « Panneaux → Git → Fenêtre détachée » pops it out.
openMenuItem("Window", "Git → nouvelle fenêtre"); pickPlacement("Git", "Fenêtre détachée");
await waitFor(() => await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
@ -80,26 +84,29 @@ describe("ProjectsView detach to OS window (#23)", () => {
it("re-toggles the placement out of detached when the OS window closes", async () => { it("re-toggles the placement out of detached when the OS window closes", async () => {
const { windowGateway, projectId } = await renderWithProject(); const { windowGateway, projectId } = await renderWithProject();
openMenuItem("Window", "Git → nouvelle fenêtre"); pickPlacement("Git", "Fenêtre détachée");
await waitFor(() => await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
); );
// The « Window » item shows the active marker (●) while detached. // The « Fenêtre détachée » submenu leaf shows the active marker (●) while
const openWindowMenu = () => // detached. Re-navigate the submenu each time to read it.
fireEvent.click(screen.getByRole("button", { name: "Window" })); const openGitSubmenu = () => {
const gitWindowItem = () => fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
screen.getByRole("button", { name: "Git → nouvelle fenêtre" }); fireEvent.click(screen.getByRole("button", { name: "Git" }));
};
const detachedLeaf = () =>
screen.getByRole("button", { name: "Fenêtre détachée" });
openWindowMenu(); openGitSubmenu();
await waitFor(() => expect(gitWindowItem().textContent).toContain("●")); await waitFor(() => expect(detachedLeaf().textContent).toContain("●"));
openWindowMenu(); // close the dropdown fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
// The backend reports the OS window closed ⇒ the slot leaves "detached". // The backend reports the OS window closed ⇒ the slot leaves "detached".
windowGateway.simulateOsClose("git", projectId); windowGateway.simulateOsClose("git", projectId);
openWindowMenu(); openGitSubmenu();
await waitFor(() => await waitFor(() =>
expect(gitWindowItem().textContent).not.toContain("●"), expect(detachedLeaf().textContent).not.toContain("●"),
); );
}); });
}); });

View File

@ -47,10 +47,15 @@ async function renderWithProject() {
); );
} }
/** Opens a menu-bar dropdown item. */ /**
function openMenuItem(menu: string, item: string) { * Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
fireEvent.click(screen.getByRole("button", { name: menu })); * entry (opens its placement submenu) → « Flottant ». `panelTitle` is the
fireEvent.click(screen.getByRole("button", { name: item })); * panel's human title ("Git", "Agents", …).
*/
function openPanelFloating(panelTitle: string) {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
} }
describe("ProjectsView view docking (#22)", () => { describe("ProjectsView view docking (#22)", () => {
@ -58,7 +63,7 @@ describe("ProjectsView view docking (#22)", () => {
await renderWithProject(); await renderWithProject();
// View → Git opens a modal floating window (historical behaviour). // View → Git opens a modal floating window (historical behaviour).
openMenuItem("View", "Git"); openPanelFloating("Git");
const dialog = await screen.findByRole("dialog", { name: "Git" }); const dialog = await screen.findByRole("dialog", { name: "Git" });
expect(dialog).toBeTruthy(); expect(dialog).toBeTruthy();
@ -78,7 +83,7 @@ describe("ProjectsView view docking (#22)", () => {
it("keeps one slot per view: docking right moves it off the left dock", async () => { it("keeps one slot per view: docking right moves it off the left dock", async () => {
await renderWithProject(); await renderWithProject();
openMenuItem("View", "Git"); openPanelFloating("Git");
const dialog = await screen.findByRole("dialog", { name: "Git" }); const dialog = await screen.findByRole("dialog", { name: "Git" });
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" })); fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
@ -100,7 +105,7 @@ describe("ProjectsView view docking (#22)", () => {
await renderWithProject(); await renderWithProject();
// Dock Git left. // Dock Git left.
openMenuItem("View", "Git"); openPanelFloating("Git");
fireEvent.click( fireEvent.click(
within(await screen.findByRole("dialog", { name: "Git" })).getByRole( within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
"button", "button",
@ -108,7 +113,7 @@ describe("ProjectsView view docking (#22)", () => {
), ),
); );
// Dock Agents right. // Dock Agents right.
openMenuItem("View", "Agents"); openPanelFloating("Agents");
fireEvent.click( fireEvent.click(
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole( within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
"button", "button",

View File

@ -112,10 +112,14 @@ function renderView(project: MockProjectGateway) {
); );
} }
/** Opens a menu-bar dropdown item (#16 chrome). */ /**
function openMenuItem(menu: string, item: string) { * Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
fireEvent.click(screen.getByRole("button", { name: menu })); * entry (opens its placement submenu) → « Flottant ».
fireEvent.click(screen.getByRole("button", { name: item })); */
function openPanel(panelTitle: string) {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
} }
async function openProjectAndWorkTab(label: string) { async function openProjectAndWorkTab(label: string) {
@ -127,7 +131,7 @@ async function openProjectAndWorkTab(label: string) {
fireEvent.click(within(li!).getByRole("button", { name: "Open" })); fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
await screen.findByRole("tab"); await screen.findByRole("tab");
// Open the Work panel window from the View menu. // Open the Work panel window from the View menu.
openMenuItem("View", "Work"); openPanel("Work state");
} }
describe("ProjectsView — LS7 conversation viewer integration", () => { describe("ProjectsView — LS7 conversation viewer integration", () => {
@ -177,7 +181,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
// persists across activation (the inline welcome list is replaced by the // persists across activation (the inline welcome list is replaced by the
// grid once a project is active). // grid once a project is active).
await screen.findByText("/p/a"); await screen.findByText("/p/a");
openMenuItem("File", "Projects"); openPanel("Projects");
for (const root of ["/p/a", "/p/b"]) { for (const root of ["/p/a", "/p/b"]) {
const li = screen const li = screen
.getAllByRole("listitem") .getAllByRole("listitem")
@ -188,7 +192,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
// Make alpha the active tab, then open its conversation viewer. // Make alpha the active tab, then open its conversation viewer.
fireEvent.click(screen.getByRole("tab", { name: "alpha" })); fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
openMenuItem("View", "Work"); openPanel("Work state");
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }), await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
); );

View File

@ -4,20 +4,22 @@
* IDE layout (full remaining height), reworked in ticket #16: * IDE layout (full remaining height), reworked in ticket #16:
* *
* ┌───────────────────────────────────────────────────────┐ * ┌───────────────────────────────────────────────────────┐
* │ PROJECT TABS [ alpha × ][ beta × ] * │ PROJECT TABS [ alpha × ][ beta × ] [ + ]
* ├───────────────────────────────────────────────────────┤ * ├───────────────────────────────────────────────────────┤
* │ MENU BAR File │ View * │ MENU BAR Panneaux │ Settings
* ├───────────────────────────────────────────────────────┤ * ├───────────────────────────────────────────────────────┤
* │ MAIN — LayoutGrid (fills the FULL width) │ * │ MAIN — LayoutGrid (fills the FULL width) │
* │ — or the projects manager / welcome when no project │ * │ — or the projects manager / welcome when no project │
* └───────────────────────────────────────────────────────┘ * └───────────────────────────────────────────────────────┘
* *
* The former left sidebar is gone: every panel (context, work, tickets, agents, * The former left sidebar is gone: every panel (context, work, tickets, agents,
* templates, skills, permissions, memory, git) is now reached from the **View** * templates, skills, permissions, memory, git, projects) is reached from the
* menu and shown in a chrome-level {@link FloatingWindow}, freeing the main area * single **Panneaux** menu (#26), whose per-panel submenu picks the placement
* for the terminal grid. Project creation/opening lives under the **File** menu * directly — closed / floating / docked left/right / detached OS window — and
* and, when no project is open, inline in the welcome area (so it is always * shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
* reachable without a menu). * Project creation/switching is the **Projects** panel, opened by the tab-bar
* "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in
* the welcome area (so it is always reachable).
* *
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via * Pure presentation: all behaviour comes from {@link useProjects}. Styling via
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`. * `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
@ -41,13 +43,14 @@ import {
Input, Input,
MenuBar, MenuBar,
Panel, Panel,
Tabs,
zIndex, zIndex,
type FloatingWindowSize, type FloatingWindowSize,
type MenuBarItem,
type MenuBarMenu, type MenuBarMenu,
} from "@/shared"; } from "@/shared";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects"; import { useProjects } from "./useProjects";
import { ProjectTabs } from "./ProjectTabs";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody"; import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import { import {
PANEL_TITLE, PANEL_TITLE,
@ -206,14 +209,6 @@ export function ProjectsView() {
return next; return next;
}); });
} }
// Menu toggle: open floating when closed, otherwise close (whatever the slot).
function toggleFloating(panel: PanelId) {
if (placementOf(placements, panel) === "closed") {
setPlacement(panel, "floating");
} else {
closePanel(panel);
}
}
// Dismiss every floating view (docked views stay put). Used when a full-main // Dismiss every floating view (docked views stay put). Used when a full-main
// surface takes over so a modal window doesn't obscure it. // surface takes over so a modal window doesn't obscure it.
function dismissFloating() { function dismissFloating() {
@ -279,88 +274,77 @@ export function ProjectsView() {
dismissFloating(); dismissFloating();
} }
// Switch the active project from the always-visible selector: activate its // ── Menus (#26) ─────────────────────────────────────────────────────────
// open tab if present, otherwise open it (adds a tab). Leaving Settings so the // A single « Panneaux » menu: one entry per panel, each opening a submenu with
// project surface is shown. // the placement actions directly (closed / floating / docked left/right /
function selectProject(projectId: string) { // detached OS window). One place to pick the panel *and* its mode; the current
setShowSettings(false); // placement is marked in the submenu (and the parent entry shows ● when the
if (vm.openTabs.some((t) => t.id === projectId)) { // panel is open somewhere). Order per UX: content panels first, Projects last.
vm.activateTab(projectId); const panelOrder: PanelId[] = [
} else { "context",
void vm.openProject(projectId); "work",
"tickets",
"agents",
"templates",
"skills",
"permissions",
"memory",
"git",
"projects",
];
// Placement actions for one panel, marking the active slot. `projects` is
// main-window-only chrome, so it never offers the detached-window action.
function placementSubmenu(panel: PanelId): MenuBarItem[] {
const placement = placementOf(placements, panel);
const items: MenuBarItem[] = [
{
id: "closed",
label: "Fermé",
active: placement === "closed",
onSelect: () => closePanel(panel),
},
{
id: "floating",
label: "Flottant",
active: placement === "floating",
onSelect: () => setPlacement(panel, "floating"),
},
{
id: "dock-left",
label: "Ancré à gauche",
active: isDockedTo(placement, "left"),
onSelect: () => setPlacement(panel, { dock: "left" }),
},
{
id: "dock-right",
label: "Ancré à droite",
active: isDockedTo(placement, "right"),
onSelect: () => setPlacement(panel, { dock: "right" }),
},
];
if (panel !== "projects") {
items.push({
id: "detached",
label: "Fenêtre détachée",
active: isDetached(placement),
disabled: !active,
onSelect: () => detachPanel(panel),
});
} }
return items;
} }
// ── Menus ──────────────────────────────────────────────────────────────
const viewItems: { id: PanelId; label: string }[] = [
{ id: "context", label: "Context" },
{ id: "work", label: "Work" },
{ id: "tickets", label: "Tickets" },
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "skills", label: "Skills" },
{ id: "permissions", label: "Perms" },
{ id: "memory", label: "Memory" },
{ id: "git", label: "Git" },
];
// Always-visible project selector (left of the menus): shows the active
// project and lists every known project so the user can switch without opening
// the Projects window (#16 UX pass).
const projectMenu: MenuBarMenu = {
id: "project",
label: `Projet : ${active?.name ?? "—"}`,
items:
vm.projects.length === 0
? [
{
id: "none",
label: "No projects yet",
disabled: true,
onSelect: () => {},
},
]
: vm.projects.map((p) => ({
id: p.id,
label: p.name,
active: p.id === active?.id,
onSelect: () => selectProject(p.id),
})),
};
const menus: MenuBarMenu[] = [ const menus: MenuBarMenu[] = [
projectMenu,
{ {
id: "file", id: "panels",
label: "File", label: "Panneaux",
items: [ items: panelOrder.map((panel) => ({
{ id: panel,
id: "projects", label: PANEL_TITLE[panel],
label: "Projects…", active: placementOf(placements, panel) !== "closed",
active: placementOf(placements, "projects") !== "closed", onSelect: () => {},
onSelect: () => toggleFloating("projects"), submenu: placementSubmenu(panel),
},
],
},
{
id: "view",
label: "View",
items: viewItems.map((it) => ({
id: it.id,
label: it.label,
active: placementOf(placements, it.id) !== "closed",
onSelect: () => toggleFloating(it.id),
})),
},
{
// #23: pop a view out into its own OS window. Disabled without an active
// project (the detached window is project-scoped); active ⇒ detached.
id: "window",
label: "Window",
items: viewItems.map((it) => ({
id: it.id,
label: `${PANEL_TITLE[it.id]} → nouvelle fenêtre`,
active: isDetached(placementOf(placements, it.id)),
disabled: !active,
onSelect: () => detachPanel(it.id),
})), })),
}, },
{ {
@ -575,20 +559,18 @@ export function ProjectsView() {
</p> </p>
)} )}
{/* ── Project tab bar ── */} {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1"> <ProjectTabs
{vm.openTabs.length === 0 ? (
<p className="px-2 text-sm text-muted">No open tabs.</p>
) : (
<Tabs
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))} items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
value={vm.activeTabId} activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)} onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)} onClose={(id) => void vm.closeTab(id)}
className="flex-1" projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {
setShowSettings(false);
setPlacement("projects", "floating");
}}
/> />
)}
</div>
{/* ── Menu bar (replaces the former left sidebar) ── */} {/* ── Menu bar (replaces the former left sidebar) ── */}
<MenuBar menus={menus} /> <MenuBar menus={menus} />

View File

@ -75,11 +75,19 @@ function tablist() {
} }
/** /**
* Opens a menu-bar dropdown item (#16 chrome). `menu` is a top-level menu * Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
* ("File"/"View"), `item` a panel entry ("Work", "Agents", "Projects…", …). * click the panel entry (opens its placement submenu), then pick « Flottant ».
* The panel then shows in its floating window. * `panelTitle` is the panel's human title ("Work state", "Project context",
* "Agents", "Git", "Projects", …).
*/ */
function openPanel(menu: string, item: string) { function openPanel(panelTitle: string) {
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
fireEvent.click(screen.getByRole("button", { name: panelTitle }));
fireEvent.click(screen.getByRole("button", { name: "Flottant" }));
}
/** Clicks an item in a flat top-level menu (e.g. Settings). */
function openMenuItem(menu: string, item: string) {
fireEvent.click(screen.getByRole("button", { name: menu })); fireEvent.click(screen.getByRole("button", { name: menu }));
fireEvent.click(screen.getByRole("button", { name: item })); fireEvent.click(screen.getByRole("button", { name: item }));
} }
@ -97,7 +105,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(tab.getAttribute("aria-selected")).toBe("true"); expect(tab.getAttribute("aria-selected")).toBe("true");
expect(within(tab).getByText("alpha")).toBeTruthy(); expect(within(tab).getByText("alpha")).toBeTruthy();
// And it is listed in the projects manager (File → Projects…) with its root. // And it is listed in the projects manager (File → Projects…) with its root.
openPanel("File", "Projects"); openPanel("Projects");
expect( expect(
(await screen.findAllByText("/home/me/alpha")).length, (await screen.findAllByText("/home/me/alpha")).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
@ -116,7 +124,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
// Open the projects manager window so the list persists across activation // Open the projects manager window so the list persists across activation
// (the inline welcome list is replaced by the terminal grid once active). // (the inline welcome list is replaced by the terminal grid once active).
openPanel("File", "Projects"); openPanel("Projects");
// Open alpha, then beta — re-querying the list after each activation. // Open alpha, then beta — re-querying the list after each activation.
const openInList = (name: string) => const openInList = (name: string) =>
@ -155,12 +163,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
fireEvent.click(screen.getByRole("button", { name: "Open" })); fireEvent.click(screen.getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" }); await screen.findByRole("tab", { name: "alpha" });
openPanel("View", "Work"); openPanel("Work state");
expect(await screen.findByText("No agent work state.")).toBeTruthy(); expect(await screen.findByText("No agent work state.")).toBeTruthy();
}); });
it("switches the active project from the always-visible project selector (#16)", async () => { it("switches the active project via the tab-bar + button → Projects panel (#26)", async () => {
const project = new MockProjectGateway(); const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a"); await project.createProject("alpha", "/p/a");
await project.createProject("beta", "/p/b"); await project.createProject("beta", "/p/b");
@ -174,17 +182,15 @@ describe("ProjectsView (with MockProjectGateway)", () => {
fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" })); fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" }); await screen.findByRole("tab", { name: "alpha" });
// The selector reflects the active project and lists all known projects. // The "+" button opens the Projects panel (floating); pick beta from its
fireEvent.click(screen.getByRole("button", { name: "Projet : alpha" })); // known-projects list — no verbose "Projet : …" selector anymore.
// Pick beta from the selector — it opens/activates without the Projects window. fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
fireEvent.click(screen.getByRole("button", { name: "beta" })); const betaLi = await waitFor(() =>
screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!,
);
fireEvent.click(within(betaLi).getByRole("button", { name: "Open" }));
await waitFor(() => await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
expect(screen.getByRole("button", { name: "Projet : beta" })).toBeTruthy(),
);
await waitFor(() =>
expect(screen.getAllByRole("tab")).toHaveLength(2),
);
const betaTab = screen const betaTab = screen
.getAllByRole("tab") .getAllByRole("tab")
.find((t) => within(t).queryByText("beta"))!; .find((t) => within(t).queryByText("beta"))!;
@ -200,12 +206,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.queryByLabelText("ai profiles settings")).toBeNull(); expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// Settings → AI Profiles swaps the main area to the profiles settings. // Settings → AI Profiles swaps the main area to the profiles settings.
openPanel("Settings", "AI Profiles"); openMenuItem("Settings", "AI Profiles");
expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy(); expect(await screen.findByLabelText("ai profiles settings")).toBeTruthy();
expect(screen.queryByLabelText("project name")).toBeNull(); expect(screen.queryByLabelText("project name")).toBeNull();
// Settings → Close AI Profiles returns to the project surface. // Settings → Close AI Profiles returns to the project surface.
openPanel("Settings", "Close AI Profiles"); openMenuItem("Settings", "Close AI Profiles");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(), expect(screen.getByLabelText("project name")).toBeTruthy(),
); );
@ -233,7 +239,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
// Reopen the projects manager (the inline form is replaced by the grid once // Reopen the projects manager (the inline form is replaced by the grid once
// a project is active), then create a second project at the same root. // a project is active), then create a second project at the same root.
openPanel("File", "Projects"); openPanel("Projects");
await createProject("beta", "/dup/root"); await createProject("beta", "/dup/root");
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
@ -255,40 +261,40 @@ describe("ProjectsView (with MockProjectGateway)", () => {
); );
// File → Projects… opens the projects manager window — form inputs visible. // File → Projects… opens the projects manager window — form inputs visible.
openPanel("File", "Projects"); openPanel("Projects");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(), expect(screen.getByLabelText("project name")).toBeTruthy(),
); );
// View → Agents — agent form visible; project form window is replaced. // View → Agents — agent form visible; project form window is replaced.
openPanel("View", "Agents"); openPanel("Agents");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(), expect(screen.getByLabelText("agent name")).toBeTruthy(),
); );
expect(screen.queryByLabelText("project name")).toBeNull(); expect(screen.queryByLabelText("project name")).toBeNull();
// View → Context — shared project context editor visible. // View → Context — shared project context editor visible.
openPanel("View", "Context"); openPanel("Project context");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("project context")).toBeTruthy(), expect(screen.getByLabelText("project context")).toBeTruthy(),
); );
expect(screen.queryByLabelText("agent name")).toBeNull(); expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Templates — the "New template" button visible. // View → Templates — the "New template" button visible.
openPanel("View", "Templates"); openPanel("Templates");
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(), expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
); );
expect(screen.queryByLabelText("agent name")).toBeNull(); expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Git — commit message input visible. // View → Git — commit message input visible.
openPanel("View", "Git"); openPanel("Git");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(), expect(screen.getByLabelText("commit message")).toBeTruthy(),
); );
// File → Projects… again — form accessible once more. // File → Projects… again — form accessible once more.
openPanel("File", "Projects"); openPanel("Projects");
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(), expect(screen.getByLabelText("project name")).toBeTruthy(),
); );
@ -302,7 +308,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await screen.findByText("/p/a"); await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" })); fireEvent.click(screen.getByRole("button", { name: "Open" }));
openPanel("View", "Context"); openPanel("Project context");
const editor = (await screen.findByLabelText( const editor = (await screen.findByLabelText(
"project context", "project context",

View File

@ -23,6 +23,12 @@ export interface MenuBarItem {
disabled?: boolean; disabled?: boolean;
/** Renders a selected/active marker (e.g. the currently-open panel). */ /** Renders a selected/active marker (e.g. the currently-open panel). */
active?: boolean; active?: boolean;
/**
* Nested items shown in a side flyout (#26). When present, the item opens its
* submenu on hover/click instead of running `onSelect`; selecting a leaf item
* of the submenu runs that leaf's `onSelect` and closes the whole menu.
*/
submenu?: MenuBarItem[];
} }
export interface MenuBarMenu { export interface MenuBarMenu {
@ -38,16 +44,24 @@ export interface MenuBarProps {
export function MenuBar({ menus, className }: MenuBarProps) { export function MenuBar({ menus, className }: MenuBarProps) {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
// Which item's submenu flyout is currently open (at most one, #26). Reset
// whenever the top-level menu changes or closes.
const [openSubId, setOpenSubId] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
function closeMenus() {
setOpenId(null);
setOpenSubId(null);
}
// Close on outside click and on Escape while a menu is open. // Close on outside click and on Escape while a menu is open.
useEffect(() => { useEffect(() => {
if (openId === null) return; if (openId === null) return;
function onPointerDown(e: MouseEvent) { function onPointerDown(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpenId(null); if (!rootRef.current?.contains(e.target as Node)) closeMenus();
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpenId(null); if (e.key === "Escape") closeMenus();
} }
document.addEventListener("mousedown", onPointerDown); document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
@ -73,7 +87,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
type="button" type="button"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpenId((cur) => (cur === menu.id ? null : menu.id))} onClick={() => {
setOpenSubId(null);
setOpenId((cur) => (cur === menu.id ? null : menu.id));
}}
className={cn( className={cn(
"rounded px-2.5 py-1 text-sm transition-colors", "rounded px-2.5 py-1 text-sm transition-colors",
open open
@ -93,14 +110,31 @@ export function MenuBar({ menus, className }: MenuBarProps) {
"bg-surface py-1 shadow-xl", "bg-surface py-1 shadow-xl",
)} )}
> >
{menu.items.map((item) => ( {menu.items.map((item) => {
<button const hasSub = (item.submenu?.length ?? 0) > 0;
const subOpen = hasSub && openSubId === item.id;
return (
<div
key={item.id} key={item.id}
className="relative"
onMouseEnter={() =>
setOpenSubId(hasSub ? item.id : null)
}
>
<button
type="button" type="button"
disabled={item.disabled} disabled={item.disabled}
aria-haspopup={hasSub ? "menu" : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onClick={() => { onClick={() => {
if (hasSub) {
setOpenSubId((cur) =>
cur === item.id ? null : item.id,
);
return;
}
item.onSelect(); item.onSelect();
setOpenId(null); closeMenus();
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors", "flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors",
@ -109,7 +143,45 @@ export function MenuBar({ menus, className }: MenuBarProps) {
)} )}
> >
<span>{item.label}</span> <span>{item.label}</span>
{item.active && ( {hasSub ? (
<span aria-hidden className="text-muted">
{item.active ? "● ▸" : "▸"}
</span>
) : (
item.active && (
<span aria-hidden className="text-primary">
</span>
)
)}
</button>
{subOpen && (
<div
role="menu"
aria-label={item.label}
style={{ zIndex: zIndex.floatingWindowNested }}
className={cn(
"absolute left-full top-0 ml-1 min-w-44 rounded-md border border-border",
"bg-surface py-1 shadow-xl",
)}
>
{item.submenu!.map((sub) => (
<button
key={sub.id}
type="button"
disabled={sub.disabled}
onClick={() => {
sub.onSelect();
closeMenus();
}}
className={cn(
"flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm transition-colors",
"text-content hover:bg-raised",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent",
)}
>
<span>{sub.label}</span>
{sub.active && (
<span aria-hidden className="text-primary"> <span aria-hidden className="text-primary">
</span> </span>
@ -122,5 +194,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
); );
})} })}
</div> </div>
)}
</div>
);
})}
</div>
); );
} }