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

@ -110,16 +110,22 @@ 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 }));
}
async function openProjectAndWorkTab(label: string) {
await screen.findByText(label);
// Open the project whose root is `label`.
// Open the project whose root is `label` from the welcome projects list.
const li = screen
.getAllByRole("listitem")
.find((node) => within(node).queryByText(label));
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
await screen.findByRole("tab");
// Switch the sidebar to the Work panel.
fireEvent.click(screen.getByRole("button", { name: "Work" }));
// Open the Work panel window from the View menu.
openMenuItem("View", "Work");
}
describe("ProjectsView — LS7 conversation viewer integration", () => {
@ -165,8 +171,11 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
await project.createProject("beta", "/p/b");
renderView(project);
// Open BOTH projects as tabs (while the Projects sidebar list is visible).
// Open BOTH projects as tabs. Use the projects manager window so the list
// 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…");
for (const root of ["/p/a", "/p/b"]) {
const li = screen
.getAllByRole("listitem")
@ -177,7 +186,7 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
// Make alpha the active tab, then open its conversation viewer.
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
fireEvent.click(screen.getByRole("button", { name: "Work" }));
openMenuItem("View", "Work");
fireEvent.click(
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
);

View File

@ -1,34 +1,33 @@
/**
* `ProjectsView` — the top-level project surface (L2 / L11).
*
* IDE layout (full remaining height):
* IDE layout (full remaining height), reworked in ticket #16:
*
* ┌───────────────────────────────────────────────────────┐
* │ PROJECT TABS [ alpha × ][ beta × ] [+]
* ├──────────────────────────────────────────────────────┤
* │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining)
* │ [Proj][Agents]│ — or welcome screen when no active │
* │ [Tmpl][Git] │
* │ panel body │
* └──────────────────────────────────────────────────────┘
* │ PROJECT TABS [ alpha × ][ beta × ]
* ├──────────────────────────────────────────────────────┤
* │ MENU BAR File │ View
* ├───────────────────────────────────────────────────────┤
* │ MAIN — LayoutGrid (fills the FULL width)
* │ — or the projects manager / welcome when no project
* └──────────────────────────────────────────────────────┘
*
* The "Projects" sidebar tab always hosts the create-project form and the
* known-projects list, ensuring they are always in the DOM (required by tests).
* 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).
*
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
* `@/shared`; no inline styles, no `invoke()`.
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
*
* **Test-contract** — the following query hooks from `projects.test.tsx` are
* always present in the DOM regardless of which sidebar tab is active:
* - getByLabelText("project name") / ("project root") — form inputs
* - getByRole("button", { name: "Create project" }) / "Refresh" / "Open" / "close <name>"
* - getByRole("tablist") + getAllByRole("tab") + aria-selected — project tabs
* - getByText("No projects yet.") / "No open tabs." — empty states
* - getAllByRole("listitem") — known-projects list
* - getByRole("alert") — error display
* **Test-contract** — the create-project form + known-projects list are in the
* DOM whenever no project is active (welcome area) or when the Projects window is
* open; the project tab bar (`role="tablist"`) is always present.
*/
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
@ -41,13 +40,25 @@ import { PermissionsPanel } from "@/features/permissions";
import { ProjectWorkStatePanel } from "@/features/workstate";
import { TicketsView } from "@/features/tickets";
import { ConversationViewer } from "@/features/conversations";
import { ProfilesSettings } from "@/features/first-run";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import {
Button,
FloatingWindow,
Input,
MenuBar,
Panel,
Tabs,
zIndex,
type FloatingWindowSize,
type MenuBarMenu,
} from "@/shared";
import { useGateways } from "@/app/di";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { useProjects } from "./useProjects";
type SidebarTab =
/** A panel reachable from the menu bar; each opens in a floating window. */
type PanelId =
| "projects"
| "context"
| "work"
@ -59,18 +70,33 @@ type SidebarTab =
| "memory"
| "git";
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "projects", label: "Projects" },
{ 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" },
];
/** Human titles for each panel's floating window (also its aria-label). */
const PANEL_TITLE: Record<PanelId, string> = {
projects: "Projects",
context: "Project context",
work: "Work state",
tickets: "Tickets",
agents: "Agents",
templates: "Templates",
skills: "Skills",
permissions: "Permissions",
memory: "Memory",
git: "Git",
};
/** Window width preset per panel (content-heavy panels get more room). */
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
projects: "md",
context: "lg",
work: "lg",
tickets: "lg",
agents: "lg",
templates: "lg",
skills: "lg",
permissions: "md",
memory: "lg",
git: "lg",
};
interface BackgroundTaskToast {
id: string;
@ -97,8 +123,12 @@ export function ProjectsView() {
const { system } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("projects");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Which panel's floating window is open (null = none). Replaces the sidebar.
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
// Top-level view switch (#16): when true, the main area shows the AI Profiles
// settings instead of the project surface. The single menu bar stays visible
// so the user can toggle back from Settings → AI Profiles.
const [showSettings, setShowSettings] = useState(false);
// The active layout (id + kind), reported by LayoutTabs — the single source of
// truth. `kind` decides whether the main area is the terminal grid or the git
// graph view.
@ -166,6 +196,220 @@ export function ProjectsView() {
if (picked !== null) setRoot(picked);
}
// Opening a conversation viewer takes over the main area — close any panel
// window so it doesn't obscure the viewer (LS7).
function openConversation(conversationId: string) {
setViewerConversationId(conversationId);
setOpenPanel(null);
}
// 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 ──────────────────────────────────────────────────────────────
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: openPanel === "projects",
onSelect: () => setOpenPanel("projects"),
},
],
},
{
id: "view",
label: "View",
items: viewItems.map((it) => ({
id: it.id,
label: it.label,
active: openPanel === it.id,
onSelect: () => setOpenPanel(it.id),
})),
},
{
id: "settings",
label: "Settings",
items: [
{
id: "ai-profiles",
label: showSettings ? "Close AI Profiles" : "AI Profiles",
active: showSettings,
onSelect: () => {
setShowSettings((v) => !v);
setOpenPanel(null);
},
},
],
},
];
// The create-project form + known-projects list. Rendered inline in the
// welcome area (no active project) or inside the Projects floating window.
const projectsManager: ReactNode = (
<div className="flex flex-col gap-4">
<form onSubmit={submit} className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
New project
</h3>
<Input
aria-label="project name"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex gap-2">
<Input
aria-label="project root"
placeholder="/absolute/project/root"
value={root}
onChange={(e) => setRoot(e.target.value)}
className="flex-1"
/>
<Button
type="button"
aria-label="browse project folder"
onClick={() => void handleBrowse()}
disabled={vm.busy}
>
Browse
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
disabled={!canCreate}
className="flex-1"
>
Create project
</Button>
<Button
type="button"
onClick={() => void vm.refresh()}
disabled={vm.busy}
>
Refresh
</Button>
</div>
</form>
<Panel title="Known projects">
{vm.projects.length === 0 ? (
<p className="text-sm text-muted">No projects yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
Open
</Button>
</li>
))}
</ul>
)}
</Panel>
</div>
);
// Body of the currently-open panel window (null ⇒ no window).
function renderPanel(panel: PanelId): ReactNode {
if (panel === "projects") return projectsManager;
if (!active) {
return (
<p className="text-sm text-muted">Open a project to use this panel.</p>
);
}
switch (panel) {
case "context":
return <ProjectContextPanel projectId={active.id} />;
case "work":
return (
<ProjectWorkStatePanel
projectId={active.id}
onOpenConversation={openConversation}
/>
);
case "tickets":
return <TicketsView projectId={active.id} />;
case "agents":
return <AgentsPanel projectId={active.id} projectRoot={active.root} />;
case "templates":
return <TemplatesPanel projectId={active.id} />;
case "skills":
return <SkillsPanel projectId={active.id} />;
case "permissions":
return <PermissionsPanel projectId={active.id} />;
case "memory":
return (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={active.id} />
<EmbedderSettings />
</div>
);
case "git":
return <GitPanel projectId={active.id} />;
}
}
// Projects manager renders inline in the welcome area only when it is not
// already shown in the floating window (avoids duplicate form inputs).
const showInlineProjects = !active && openPanel !== "projects";
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* ── Error alert ── */}
@ -193,279 +437,96 @@ export function ProjectsView() {
)}
</div>
{/* ── IDE body: sidebar + main ── */}
<div className="flex flex-1 overflow-hidden">
{/* ── Collapsed sidebar: a thin rail with an expand button ── */}
{sidebarCollapsed && (
<aside className="flex w-9 shrink-0 flex-col items-center border-r border-border bg-surface py-1">
<button
type="button"
aria-label="expand sidebar"
title="Expand sidebar"
onClick={() => setSidebarCollapsed(false)}
className="flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-raised hover:text-content"
>
»
</button>
</aside>
)}
{/* ── Menu bar (replaces the former left sidebar) ── */}
<MenuBar menus={menus} />
{/* ── Sidebar ── */}
<aside
className={cn(
"flex w-80 shrink-0 flex-col border-r border-border bg-surface",
sidebarCollapsed && "hidden",
)}
>
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs).
The tabs wrap onto multiple rows when they exceed the sidebar width
so every label stays visible without scrolling; the collapse button
stays pinned to the top-right. */}
<div className="flex shrink-0 items-start border-b border-border">
<div className="flex min-w-0 flex-1 flex-wrap items-stretch">
{SIDEBAR_TABS.map((t) => (
<button
key={t.id}
type="button"
aria-selected={sidebarTab === t.id}
onClick={() => setSidebarTab(t.id)}
className={cn(
"shrink-0 whitespace-nowrap px-3 py-2 text-xs font-medium transition-colors",
sidebarTab === t.id
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
<main className="flex flex-1 flex-col overflow-hidden">
{showSettings ? (
// Top-level view switch (#16): AI Profiles settings takes over the main
// area while the menu bar above stays visible to toggle back.
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl">
<ProfilesSettings />
</div>
<button
type="button"
aria-label="collapse sidebar"
title="Collapse sidebar"
onClick={() => setSidebarCollapsed(true)}
className="shrink-0 self-stretch border-l border-border px-2 text-muted hover:text-content"
>
«
</button>
</div>
{/* Sidebar panel body */}
<div className="flex-1 overflow-auto p-3">
{/* Projects panel */}
{sidebarTab === "projects" && (
<div className="flex flex-col gap-4">
{/* Create form */}
<form
onSubmit={submit}
className="flex flex-col gap-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
New project
</h3>
<Input
aria-label="project name"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex gap-2">
<Input
aria-label="project root"
placeholder="/absolute/project/root"
value={root}
onChange={(e) => setRoot(e.target.value)}
className="flex-1"
/>
<Button
type="button"
aria-label="browse project folder"
onClick={() => void handleBrowse()}
disabled={vm.busy}
>
Browse
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
disabled={!canCreate}
className="flex-1"
>
Create project
</Button>
<Button
type="button"
onClick={() => void vm.refresh()}
disabled={vm.busy}
>
Refresh
</Button>
</div>
</form>
{/* Known projects list */}
<Panel title="Known projects">
{vm.projects.length === 0 ? (
<p className="text-sm text-muted">No projects yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button
size="sm"
onClick={() => void vm.openProject(p.id)}
>
Open
</Button>
</li>
))}
</ul>
)}
</Panel>
</div>
)}
{/* Project context panel */}
{sidebarTab === "context" && active && (
<ProjectContextPanel projectId={active.id} />
)}
{sidebarTab === "context" && !active && (
<p className="text-sm text-muted">Open a project to edit context.</p>
)}
{/* Work-state panel */}
{sidebarTab === "work" && active && (
<ProjectWorkStatePanel
projectId={active.id}
onOpenConversation={setViewerConversationId}
/>
)}
{sidebarTab === "work" && !active && (
<p className="text-sm text-muted">Open a project to view work state.</p>
)}
{/* Tickets panel */}
{sidebarTab === "tickets" && active && (
<TicketsView projectId={active.id} />
)}
{sidebarTab === "tickets" && !active && (
<p className="text-sm text-muted">Open a project to view tickets.</p>
)}
{/* Agents panel — only rendered when active project exists */}
{sidebarTab === "agents" && active && (
<AgentsPanel projectId={active.id} projectRoot={active.root} />
)}
{sidebarTab === "agents" && !active && (
<p className="text-sm text-muted">Open a project to manage agents.</p>
)}
{/* Templates panel */}
{sidebarTab === "templates" && active && (
<TemplatesPanel projectId={active.id} />
)}
{sidebarTab === "templates" && !active && (
<p className="text-sm text-muted">Open a project to manage templates.</p>
)}
{/* Skills panel */}
{sidebarTab === "skills" && active && (
<SkillsPanel projectId={active.id} />
)}
{sidebarTab === "skills" && !active && (
<p className="text-sm text-muted">Open a project to manage skills.</p>
)}
{/* Permissions panel */}
{sidebarTab === "permissions" && active && (
<PermissionsPanel projectId={active.id} />
)}
{sidebarTab === "permissions" && !active && (
<p className="text-sm text-muted">Open a project to manage permissions.</p>
)}
{/* Memory panel + embedder settings */}
{sidebarTab === "memory" && active && (
<div className="flex flex-col gap-4">
<MemoryPanel projectId={active.id} />
<EmbedderSettings />
</div>
)}
{sidebarTab === "memory" && !active && (
<p className="text-sm text-muted">Open a project to manage memory.</p>
)}
{/* Git panel */}
{sidebarTab === "git" && active && (
<GitPanel projectId={active.id} />
)}
{sidebarTab === "git" && !active && (
<p className="text-sm text-muted">Open a project to manage git.</p>
)}
</div>
</aside>
{/* ── Main: terminal grid or git graph (fills remaining height) ── */}
<main className="flex flex-1 flex-col overflow-hidden">
{active && viewerConversationId ? (
<ConversationViewer
key={`${active.id}-${viewerConversationId}`}
) : active && viewerConversationId ? (
<ConversationViewer
key={`${active.id}-${viewerConversationId}`}
projectId={active.id}
conversationId={viewerConversationId}
onClose={() => setViewerConversationId(null)}
/>
) : active ? (
<>
<LayoutTabs
projectId={active.id}
conversationId={viewerConversationId}
onClose={() => setViewerConversationId(null)}
onActiveLayoutChange={setActiveLayout}
/>
) : active ? (
<>
<LayoutTabs
{activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={active.id}
onActiveLayoutChange={setActiveLayout}
/>
{activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={active.id}
/>
) : (
<LayoutGrid
key={`${active.id}-${activeLayout?.id ?? "default"}`}
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={openConversation}
/>
)}
</>
) : (
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-xl">
{showInlineProjects ? (
projectsManager
) : (
<LayoutGrid
key={`${active.id}-${activeLayout?.id ?? "default"}`}
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={setViewerConversationId}
/>
<p className="text-sm text-muted">
Select or create a project to get started.
</p>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-muted">
<p className="text-sm">Select or create a project to get started.</p>
</div>
)}
</main>
</div>
</div>
)}
</main>
{/* ── Panel floating window ── */}
{openPanel && (
<FloatingWindow
open
title={PANEL_TITLE[openPanel]}
size={PANEL_SIZE[openPanel]}
onClose={() => setOpenPanel(null)}
>
{renderPanel(openPanel)}
</FloatingWindow>
)}
{/* ── Background-task toasts (above floating windows) ── */}
{taskToasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<div
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
style={{ zIndex: zIndex.toast }}
>
{taskToasts.map((toast) => (
<button
key={toast.id}
type="button"
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
onClick={() => {
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
const projectOpen = vm.openTabs.some(
(tab) => tab.id === toast.projectId,
);
if (projectOpen) vm.activateTab(toast.projectId);
setSidebarCollapsed(false);
setSidebarTab("work");
setShowSettings(false);
setViewerConversationId(null);
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
setOpenPanel("work");
setTaskToasts((prev) =>
prev.filter((item) => item.id !== toast.id),
);
}}
>
<span className="block text-sm font-medium text-content">

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",