feat(ui): menus déroulants + fenêtres flottantes, barre unique et sélecteur de projet (#16)

Lot A du sprint UI rework : réorganisation de la navigation racine.

- shared/ui/MenuBar.tsx : barre de menus unique avec menus déroulants
- shared/ui/FloatingWindow.tsx : primitive de fenêtre flottante réutilisable
  (socle du lot C #17 fenêtre dédiée gestion tickets)
- shared/ui/zIndex.ts : échelle z-index étendue pour les fenêtres flottantes
- App : intègre la barre unique et le sélecteur de projet permanent
- ProjectsView : sélecteur de projet permanent

Tests : tsc --noEmit clean, suite complète 527/527, suites impactées 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:55:37 +02:00
parent 0218e3271f
commit c55f948d25
9 changed files with 933 additions and 353 deletions

View File

@ -73,6 +73,16 @@ function tablist() {
return screen.getByRole("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.
*/
function openPanel(menu: string, item: string) {
fireEvent.click(screen.getByRole("button", { name: menu }));
fireEvent.click(screen.getByRole("button", { name: item }));
}
describe("ProjectsView (with MockProjectGateway)", () => {
it("creating a project adds it to the known list and opens a tab", async () => {
renderView();
@ -85,8 +95,11 @@ describe("ProjectsView (with MockProjectGateway)", () => {
const tab = await screen.findByRole("tab");
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(within(tab).getByText("alpha")).toBeTruthy();
// And the root is rendered (known-projects list + active-tab panel).
expect(screen.getAllByText("/home/me/alpha").length).toBeGreaterThan(0);
// And it is listed in the projects manager (File → Projects…) with its root.
openPanel("File", "Projects…");
expect(
(await screen.findAllByText("/home/me/alpha")).length,
).toBeGreaterThan(0);
});
it("opening a project creates a tab and makes it the active one", async () => {
@ -100,21 +113,22 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await screen.findByText("/p/a");
await screen.findByText("/p/b");
// Open alpha, then beta.
const items = screen.getAllByRole("listitem");
const openA = within(
items.find((li) => within(li).queryByText("alpha"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openA);
// 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…");
// Open alpha, then beta — re-querying the list after each activation.
const openInList = (name: string) =>
within(
screen.getAllByRole("listitem").find((li) => within(li).queryByText(name))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openInList("alpha"));
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
const openB = within(
items.find((li) => within(li).queryByText("beta"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openB);
fireEvent.click(openInList("beta"));
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
@ -140,11 +154,63 @@ describe("ProjectsView (with MockProjectGateway)", () => {
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" });
fireEvent.click(screen.getByRole("button", { name: "Work" }));
openPanel("View", "Work");
expect(await screen.findByText("No agent work state.")).toBeTruthy();
});
it("switches the active project from the always-visible project selector (#16)", async () => {
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
await project.createProject("beta", "/p/b");
renderView(project);
// Open alpha from the welcome list ⇒ it becomes the active tab.
await screen.findByText("/p/a");
const alphaLi = screen
.getAllByRole("listitem")
.find((n) => within(n).queryByText("alpha"))!;
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" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Projet : beta" })).toBeTruthy(),
);
await waitFor(() =>
expect(screen.getAllByRole("tab")).toHaveLength(2),
);
const betaTab = screen
.getAllByRole("tab")
.find((t) => within(t).queryByText("beta"))!;
expect(betaTab.getAttribute("aria-selected")).toBe("true");
});
it("toggles the AI Profiles view from the single Settings menu (#16)", async () => {
renderView();
await waitForIdle();
// The project surface (create form) is visible; profiles settings are not.
expect(screen.getByLabelText("project name")).toBeTruthy();
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
// Settings → AI Profiles swaps the main area to the profiles settings.
openPanel("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");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
expect(screen.queryByLabelText("ai profiles settings")).toBeNull();
});
it("closing a tab removes it from the tab bar", async () => {
renderView();
await createProject("alpha", "/home/me/alpha");
@ -164,61 +230,67 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await createProject("alpha", "/dup/root");
await screen.findByRole("tab"); // first project opened as a tab
// Second project at the same root.
// 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…");
await createProject("beta", "/dup/root");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/already exists/i);
});
it("sidebar tab switch shows the selected panel and hides others", async () => {
it("menu panels show the selected panel and hide others (#16)", async () => {
// Set up a project so that agent/template/git panels are reachable.
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
renderView(project);
// Open alpha so the sidebar panels become active.
// Open alpha so the panels become active. With a project open the main area
// is the terminal grid — panels now live behind the menu bar.
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
// Default sidebar tab is "Projects" — the form inputs must be accessible.
expect(screen.getByLabelText("project name")).toBeTruthy();
expect(screen.getByLabelText("project root")).toBeTruthy();
// File → Projects… opens the projects manager window — form inputs visible.
openPanel("File", "Projects…");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
// Switch to Agents tab — agent form should be visible.
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
// Agent panel renders an "agent name" input; project form is now hidden.
// View → Agents — agent form visible; project form window is replaced.
openPanel("View", "Agents");
await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(),
);
expect(screen.queryByLabelText("project name")).toBeNull();
// Switch to Context tab — shared project context editor should be visible.
fireEvent.click(screen.getByRole("button", { name: "Context" }));
// View → Context — shared project context editor visible.
openPanel("View", "Context");
await waitFor(() =>
expect(screen.getByLabelText("project context")).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// Switch to Templates tab — the "New template" button should be visible.
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
// View → Templates — the "New template" button visible.
openPanel("View", "Templates");
await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// Switch to Git tab — commit message input should be visible.
fireEvent.click(screen.getByRole("button", { name: "Git" }));
// View → Git — commit message input visible.
openPanel("View", "Git");
await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(),
);
// Switch back to Projects tab — form is accessible again.
fireEvent.click(screen.getByRole("button", { name: "Projects" }));
expect(screen.getByLabelText("project name")).toBeTruthy();
// File → Projects… again — form accessible once more.
openPanel("File", "Projects");
await waitFor(() =>
expect(screen.getByLabelText("project name")).toBeTruthy(),
);
});
it("edits the shared project context stored under .ideai", async () => {
@ -229,7 +301,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
fireEvent.click(screen.getByRole("button", { name: "Context" }));
openPanel("View", "Context");
const editor = (await screen.findByLabelText(
"project context",