diff --git a/frontend/src/features/projects/ProjectTabs.tsx b/frontend/src/features/projects/ProjectTabs.tsx
index 33054bb..2bec108 100644
--- a/frontend/src/features/projects/ProjectTabs.tsx
+++ b/frontend/src/features/projects/ProjectTabs.tsx
@@ -1,9 +1,13 @@
/**
* `ProjectTabs` — the project tab bar rendered below the app header.
*
- * Shows one tab per open project with a close control, and a "+" button to
- * return to the launcher. Purely presentational: all state lives in the
- * parent (ProjectsView via useProjects).
+ * Shows one tab per open project with a close control, and a "+" button to the
+ * right that opens the **Projects** panel (floating) — the single entry point
+ * 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";
@@ -14,9 +18,10 @@ export interface ProjectTabsProps {
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
- /** Whether the launcher overlay is currently visible (no active tab). */
- showingLauncher: boolean;
- onShowLauncher: () => void;
+ /** Whether the Projects panel is currently open (in any placement). */
+ projectsPanelOpen: boolean;
+ /** Open the Projects panel (floating) — create/switch project. */
+ onOpenProjectsPanel: () => void;
className?: string;
}
@@ -25,31 +30,33 @@ export function ProjectTabs({
activeTabId,
onSelect,
onClose,
- showingLauncher,
- onShowLauncher,
+ projectsPanelOpen,
+ onOpenProjectsPanel,
className,
}: ProjectTabsProps) {
- if (items.length === 0) return null;
-
return (
-
+ {items.length === 0 ? (
+
No open tabs.
+ ) : (
+
+ )}
+
diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx
index 92664db..ed5b71f 100644
--- a/frontend/src/features/projects/ProjectsView.detach.test.tsx
+++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx
@@ -49,23 +49,27 @@ async function renderWithProject() {
return { windowGateway, projectId: created.id };
}
-function openMenuItem(menu: string, item: string) {
- fireEvent.click(screen.getByRole("button", { name: menu }));
- fireEvent.click(screen.getByRole("button", { name: item }));
+/**
+ * Picks a placement for a panel from the single « Panneaux » menu (#26): menu →
+ * 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)", () => {
- 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();
// A docked view is visible in the main window first…
- openMenuItem("View", "Git");
- const dialog = await screen.findByRole("dialog", { name: "Git" });
- fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
+ pickPlacement("Git", "Ancré à gauche");
await screen.findByRole("complementary", { name: "left dock" });
- // …then « Git → nouvelle fenêtre » pops it out.
- openMenuItem("Window", "Git → nouvelle fenêtre");
+ // …then « Panneaux → Git → Fenêtre détachée » pops it out.
+ pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
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 () => {
const { windowGateway, projectId } = await renderWithProject();
- openMenuItem("Window", "Git → nouvelle fenêtre");
+ pickPlacement("Git", "Fenêtre détachée");
await waitFor(() =>
expect(windowGateway.open.has(`git|${projectId}`)).toBe(true),
);
- // The « Window » item shows the active marker (●) while detached.
- const openWindowMenu = () =>
- fireEvent.click(screen.getByRole("button", { name: "Window" }));
- const gitWindowItem = () =>
- screen.getByRole("button", { name: "Git → nouvelle fenêtre" });
+ // The « Fenêtre détachée » submenu leaf shows the active marker (●) while
+ // detached. Re-navigate the submenu each time to read it.
+ const openGitSubmenu = () => {
+ fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
+ fireEvent.click(screen.getByRole("button", { name: "Git" }));
+ };
+ const detachedLeaf = () =>
+ screen.getByRole("button", { name: "Fenêtre détachée" });
- openWindowMenu();
- await waitFor(() => expect(gitWindowItem().textContent).toContain("●"));
- openWindowMenu(); // close the dropdown
+ openGitSubmenu();
+ await waitFor(() => expect(detachedLeaf().textContent).toContain("●"));
+ fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close
// The backend reports the OS window closed ⇒ the slot leaves "detached".
windowGateway.simulateOsClose("git", projectId);
- openWindowMenu();
+ openGitSubmenu();
await waitFor(() =>
- expect(gitWindowItem().textContent).not.toContain("●"),
+ expect(detachedLeaf().textContent).not.toContain("●"),
);
});
});
diff --git a/frontend/src/features/projects/ProjectsView.docking.test.tsx b/frontend/src/features/projects/ProjectsView.docking.test.tsx
index 839f1b1..de40a12 100644
--- a/frontend/src/features/projects/ProjectsView.docking.test.tsx
+++ b/frontend/src/features/projects/ProjectsView.docking.test.tsx
@@ -47,10 +47,15 @@ async function renderWithProject() {
);
}
-/** Opens a menu-bar dropdown item. */
-function openMenuItem(menu: string, item: string) {
- fireEvent.click(screen.getByRole("button", { name: menu }));
- fireEvent.click(screen.getByRole("button", { name: item }));
+/**
+ * Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
+ * entry (opens its placement submenu) → « Flottant ». `panelTitle` is the
+ * 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)", () => {
@@ -58,7 +63,7 @@ describe("ProjectsView view docking (#22)", () => {
await renderWithProject();
// View → Git opens a modal floating window (historical behaviour).
- openMenuItem("View", "Git");
+ openPanelFloating("Git");
const dialog = await screen.findByRole("dialog", { name: "Git" });
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 () => {
await renderWithProject();
- openMenuItem("View", "Git");
+ openPanelFloating("Git");
const dialog = await screen.findByRole("dialog", { name: "Git" });
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
@@ -100,7 +105,7 @@ describe("ProjectsView view docking (#22)", () => {
await renderWithProject();
// Dock Git left.
- openMenuItem("View", "Git");
+ openPanelFloating("Git");
fireEvent.click(
within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
"button",
@@ -108,7 +113,7 @@ describe("ProjectsView view docking (#22)", () => {
),
);
// Dock Agents right.
- openMenuItem("View", "Agents");
+ openPanelFloating("Agents");
fireEvent.click(
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
"button",
diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx
index e2ac38c..efed359 100644
--- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx
+++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx
@@ -112,10 +112,14 @@ function renderView(project: MockProjectGateway) {
);
}
-/** Opens a menu-bar dropdown item (#16 chrome). */
-function openMenuItem(menu: string, item: string) {
- fireEvent.click(screen.getByRole("button", { name: menu }));
- fireEvent.click(screen.getByRole("button", { name: item }));
+/**
+ * Opens a panel floating via the single « Panneaux » menu (#26): menu → panel
+ * entry (opens its placement submenu) → « Flottant ».
+ */
+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) {
@@ -127,7 +131,7 @@ async function openProjectAndWorkTab(label: string) {
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
await screen.findByRole("tab");
// Open the Work panel window from the View menu.
- openMenuItem("View", "Work");
+ openPanel("Work state");
}
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
// grid once a project is active).
await screen.findByText("/p/a");
- openMenuItem("File", "Projects…");
+ openPanel("Projects");
for (const root of ["/p/a", "/p/b"]) {
const li = screen
.getAllByRole("listitem")
@@ -188,7 +192,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
// Make alpha the active tab, then open its conversation viewer.
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
- openMenuItem("View", "Work");
+ openPanel("Work state");
fireEvent.click(
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
);
diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx
index 487faa7..0cb247e 100644
--- a/frontend/src/features/projects/ProjectsView.tsx
+++ b/frontend/src/features/projects/ProjectsView.tsx
@@ -4,20 +4,22 @@
* 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) │
* │ — or the projects manager / welcome when no project │
* └───────────────────────────────────────────────────────┘
*
* The former left sidebar is gone: every panel (context, work, tickets, agents,
- * templates, skills, permissions, memory, git) is now reached from the **View**
- * menu and shown in a chrome-level {@link FloatingWindow}, freeing the main area
- * for the terminal grid. Project creation/opening lives under the **File** menu
- * and, when no project is open, inline in the welcome area (so it is always
- * reachable without a menu).
+ * templates, skills, permissions, memory, git, projects) is reached from the
+ * single **Panneaux** menu (#26), whose per-panel submenu picks the placement
+ * directly — closed / floating / docked left/right / detached OS window — and
+ * shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
+ * 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
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
@@ -41,13 +43,14 @@ import {
Input,
MenuBar,
Panel,
- Tabs,
zIndex,
type FloatingWindowSize,
+ type MenuBarItem,
type MenuBarMenu,
} from "@/shared";
import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects";
+import { ProjectTabs } from "./ProjectTabs";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
PANEL_TITLE,
@@ -206,14 +209,6 @@ export function ProjectsView() {
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
// surface takes over so a modal window doesn't obscure it.
function dismissFloating() {
@@ -279,88 +274,77 @@ export function ProjectsView() {
dismissFloating();
}
- // Switch the active project from the always-visible selector: activate its
- // open tab if present, otherwise open it (adds a tab). Leaving Settings so the
- // project surface is shown.
- function selectProject(projectId: string) {
- setShowSettings(false);
- if (vm.openTabs.some((t) => t.id === projectId)) {
- vm.activateTab(projectId);
- } else {
- void vm.openProject(projectId);
+ // ── Menus (#26) ─────────────────────────────────────────────────────────
+ // A single « Panneaux » menu: one entry per panel, each opening a submenu with
+ // the placement actions directly (closed / floating / docked left/right /
+ // detached OS window). One place to pick the panel *and* its mode; the current
+ // placement is marked in the submenu (and the parent entry shows ● when the
+ // panel is open somewhere). Order per UX: content panels first, Projects last.
+ const panelOrder: PanelId[] = [
+ "context",
+ "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[] = [
- projectMenu,
{
- id: "file",
- label: "File",
- items: [
- {
- id: "projects",
- label: "Projects…",
- active: placementOf(placements, "projects") !== "closed",
- onSelect: () => toggleFloating("projects"),
- },
- ],
- },
- {
- 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),
+ id: "panels",
+ label: "Panneaux",
+ items: panelOrder.map((panel) => ({
+ id: panel,
+ label: PANEL_TITLE[panel],
+ active: placementOf(placements, panel) !== "closed",
+ onSelect: () => {},
+ submenu: placementSubmenu(panel),
})),
},
{
@@ -575,20 +559,18 @@ export function ProjectsView() {
)}
- {/* ── Project tab bar ── */}
-
- {vm.openTabs.length === 0 ? (
-
No open tabs.
- ) : (
-
({ id: t.id, label: t.name }))}
- value={vm.activeTabId}
- onSelect={(id) => vm.activateTab(id)}
- onClose={(id) => void vm.closeTab(id)}
- className="flex-1"
- />
- )}
-
+ {/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
+ ({ id: t.id, label: t.name }))}
+ activeTabId={vm.activeTabId}
+ onSelect={(id) => vm.activateTab(id)}
+ onClose={(id) => void vm.closeTab(id)}
+ projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
+ onOpenProjectsPanel={() => {
+ setShowSettings(false);
+ setPlacement("projects", "floating");
+ }}
+ />
{/* ── Menu bar (replaces the former left sidebar) ── */}
diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx
index 5fa700a..e30fbaa 100644
--- a/frontend/src/features/projects/projects.test.tsx
+++ b/frontend/src/features/projects/projects.test.tsx
@@ -75,11 +75,19 @@ function tablist() {
}
/**
- * Opens a menu-bar dropdown item (#16 chrome). `menu` is a top-level menu
- * ("File"/"View"), `item` a panel entry ("Work", "Agents", "Projects…", …).
- * The panel then shows in its floating window.
+ * Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
+ * click the panel entry (opens its placement submenu), then pick « Flottant ».
+ * `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: item }));
}
@@ -97,7 +105,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(within(tab).getByText("alpha")).toBeTruthy();
// And it is listed in the projects manager (File → Projects…) with its root.
- openPanel("File", "Projects…");
+ openPanel("Projects");
expect(
(await screen.findAllByText("/home/me/alpha")).length,
).toBeGreaterThan(0);
@@ -116,7 +124,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
// Open the projects manager window so the list persists across activation
// (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.
const openInList = (name: string) =>
@@ -155,12 +163,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" });
- openPanel("View", "Work");
+ openPanel("Work state");
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();
await project.createProject("alpha", "/p/a");
await project.createProject("beta", "/p/b");
@@ -174,17 +182,15 @@ describe("ProjectsView (with MockProjectGateway)", () => {
fireEvent.click(within(alphaLi).getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" });
- // The selector reflects the active project and lists all known projects.
- fireEvent.click(screen.getByRole("button", { name: "Projet : alpha" }));
- // Pick beta from the selector — it opens/activates without the Projects window.
- fireEvent.click(screen.getByRole("button", { name: "beta" }));
+ // The "+" button opens the Projects panel (floating); pick beta from its
+ // known-projects list — no verbose "Projet : …" selector anymore.
+ fireEvent.click(screen.getByRole("button", { name: "open projects panel" }));
+ const betaLi = await waitFor(() =>
+ screen.getAllByRole("listitem").find((n) => within(n).queryByText("beta"))!,
+ );
+ fireEvent.click(within(betaLi).getByRole("button", { name: "Open" }));
- await waitFor(() =>
- expect(screen.getByRole("button", { name: "Projet : beta" })).toBeTruthy(),
- );
- await waitFor(() =>
- expect(screen.getAllByRole("tab")).toHaveLength(2),
- );
+ await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2));
const betaTab = screen
.getAllByRole("tab")
.find((t) => within(t).queryByText("beta"))!;
@@ -200,12 +206,12 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// 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(screen.queryByLabelText("project name")).toBeNull();
// Settings → Close AI Profiles returns to the project surface.
- openPanel("Settings", "Close AI Profiles");
+ openMenuItem("Settings", "Close AI Profiles");
await waitFor(() =>
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
// a project is active), then create a second project at the same root.
- openPanel("File", "Projects…");
+ openPanel("Projects");
await createProject("beta", "/dup/root");
const alert = await screen.findByRole("alert");
@@ -255,40 +261,40 @@ describe("ProjectsView (with MockProjectGateway)", () => {
);
// File → Projects… opens the projects manager window — form inputs visible.
- openPanel("File", "Projects…");
+ openPanel("Projects");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
// View → Agents — agent form visible; project form window is replaced.
- openPanel("View", "Agents");
+ openPanel("Agents");
await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(),
);
expect(screen.queryByLabelText("project name")).toBeNull();
// View → Context — shared project context editor visible.
- openPanel("View", "Context");
+ openPanel("Project context");
await waitFor(() =>
expect(screen.getByLabelText("project context")).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Templates — the "New template" button visible.
- openPanel("View", "Templates");
+ openPanel("Templates");
await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// View → Git — commit message input visible.
- openPanel("View", "Git");
+ openPanel("Git");
await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(),
);
// File → Projects… again — form accessible once more.
- openPanel("File", "Projects…");
+ openPanel("Projects");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
@@ -302,7 +308,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
- openPanel("View", "Context");
+ openPanel("Project context");
const editor = (await screen.findByLabelText(
"project context",
diff --git a/frontend/src/shared/ui/MenuBar.tsx b/frontend/src/shared/ui/MenuBar.tsx
index 10d5026..d33c5a8 100644
--- a/frontend/src/shared/ui/MenuBar.tsx
+++ b/frontend/src/shared/ui/MenuBar.tsx
@@ -23,6 +23,12 @@ export interface MenuBarItem {
disabled?: boolean;
/** Renders a selected/active marker (e.g. the currently-open panel). */
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 {
@@ -38,16 +44,24 @@ export interface MenuBarProps {
export function MenuBar({ menus, className }: MenuBarProps) {
const [openId, setOpenId] = useState(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(null);
const rootRef = useRef(null);
+ function closeMenus() {
+ setOpenId(null);
+ setOpenSubId(null);
+ }
+
// Close on outside click and on Escape while a menu is open.
useEffect(() => {
if (openId === null) return;
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) {
- if (e.key === "Escape") setOpenId(null);
+ if (e.key === "Escape") closeMenus();
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
@@ -73,7 +87,10 @@ export function MenuBar({ menus, className }: MenuBarProps) {
type="button"
aria-haspopup="menu"
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(
"rounded px-2.5 py-1 text-sm transition-colors",
open
@@ -93,29 +110,89 @@ export function MenuBar({ menus, className }: MenuBarProps) {
"bg-surface py-1 shadow-xl",
)}
>
- {menu.items.map((item) => (
- {
- item.onSelect();
- setOpenId(null);
- }}
- 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",
- )}
- >
- {item.label}
- {item.active && (
-
- ●
-
- )}
-
- ))}
+ {menu.items.map((item) => {
+ const hasSub = (item.submenu?.length ?? 0) > 0;
+ const subOpen = hasSub && openSubId === item.id;
+ return (
+
+ setOpenSubId(hasSub ? item.id : null)
+ }
+ >
+
{
+ if (hasSub) {
+ setOpenSubId((cur) =>
+ cur === item.id ? null : item.id,
+ );
+ return;
+ }
+ item.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",
+ )}
+ >
+ {item.label}
+ {hasSub ? (
+
+ {item.active ? "● ▸" : "▸"}
+
+ ) : (
+ item.active && (
+
+ ●
+
+ )
+ )}
+
+ {subOpen && (
+
+ {item.submenu!.map((sub) => (
+ {
+ 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",
+ )}
+ >
+ {sub.label}
+ {sub.active && (
+
+ ●
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ })}
)}