Merge feature/ui-menus-floating into develop
Lot A du sprint UI rework : menus déroulants, FloatingWindow, barre unique et sélecteur de projet permanent (#16). Débloque le lot C #17. Tests verts (tsc clean, suite complète 527/527). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -8,9 +8,9 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import type { DomainEvent, HealthReport } from "@/domain";
|
import type { DomainEvent, HealthReport } from "@/domain";
|
||||||
import { ProjectsView } from "@/features/projects";
|
import { ProjectsView } from "@/features/projects";
|
||||||
import { FirstRunWizard, ProfilesSettings } from "@/features/first-run";
|
import { FirstRunWizard } from "@/features/first-run";
|
||||||
import { AnnouncementsProvider } from "@/features/announcements";
|
import { AnnouncementsProvider } from "@/features/announcements";
|
||||||
import { Button, Panel, Spinner, Toolbar } from "@/shared";
|
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||||
import { useGateways, shouldUseMock } from "./di";
|
import { useGateways, shouldUseMock } from "./di";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@ -20,7 +20,6 @@ export function App() {
|
|||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
// First-run gating: null while loading, then true (wizard) / false (normal).
|
// First-run gating: null while loading, then true (wizard) / false (normal).
|
||||||
const [firstRun, setFirstRun] = useState<boolean | null>(null);
|
const [firstRun, setFirstRun] = useState<boolean | null>(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsub: (() => void) | undefined;
|
let unsub: (() => void) | undefined;
|
||||||
@ -89,15 +88,6 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!firstRun && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={showSettings ? "secondary" : "ghost"}
|
|
||||||
onClick={() => setShowSettings((v) => !v)}
|
|
||||||
>
|
|
||||||
{showSettings ? "Close settings" : "AI Profiles"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -117,16 +107,9 @@ export function App() {
|
|||||||
<FirstRunWizard onDone={() => setFirstRun(false)} />
|
<FirstRunWizard onDone={() => setFirstRun(false)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : showSettings ? (
|
|
||||||
// Settings is a full scrollable view (its "Configure profiles" reopens
|
|
||||||
// the wizard, which can also exceed the viewport height).
|
|
||||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
|
||||||
<div className="w-full max-w-2xl">
|
|
||||||
<ProfilesSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
// ProjectsView owns the full remaining area (its own IDE layout).
|
// ProjectsView owns the full remaining area (its own IDE layout,
|
||||||
|
// single menu bar, and the AI Profiles view switch — ticket #16).
|
||||||
<ProjectsView />
|
<ProjectsView />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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) {
|
async function openProjectAndWorkTab(label: string) {
|
||||||
await screen.findByText(label);
|
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
|
const li = screen
|
||||||
.getAllByRole("listitem")
|
.getAllByRole("listitem")
|
||||||
.find((node) => within(node).queryByText(label));
|
.find((node) => within(node).queryByText(label));
|
||||||
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
fireEvent.click(within(li!).getByRole("button", { name: "Open" }));
|
||||||
await screen.findByRole("tab");
|
await screen.findByRole("tab");
|
||||||
// Switch the sidebar to the Work panel.
|
// Open the Work panel window from the View menu.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
openMenuItem("View", "Work");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
describe("ProjectsView — LS7 conversation viewer integration", () => {
|
||||||
@ -165,8 +171,11 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
await project.createProject("beta", "/p/b");
|
await project.createProject("beta", "/p/b");
|
||||||
renderView(project);
|
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");
|
await screen.findByText("/p/a");
|
||||||
|
openMenuItem("File", "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")
|
||||||
@ -177,7 +186,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" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
openMenuItem("View", "Work");
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
await screen.findByRole("button", { name: `open conversation ${CONV_ID}` }),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,34 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* `ProjectsView` — the top-level project surface (L2 / L11).
|
* `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 × ] [+] │
|
* │ PROJECT TABS [ alpha × ][ beta × ] │
|
||||||
* ├───────────────┬───────────────────────────────────────┤
|
* ├───────────────────────────────────────────────────────┤
|
||||||
* │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining) │
|
* │ MENU BAR File │ View │
|
||||||
* │ [Proj][Agents]│ — or welcome screen when no active │
|
* ├───────────────────────────────────────────────────────┤
|
||||||
* │ [Tmpl][Git] │ │
|
* │ MAIN — LayoutGrid (fills the FULL width) │
|
||||||
* │ panel body │ │
|
* │ — or the projects manager / welcome when no project │
|
||||||
* └───────────────┴───────────────────────────────────────┘
|
* └───────────────────────────────────────────────────────┘
|
||||||
*
|
*
|
||||||
* The "Projects" sidebar tab always hosts the create-project form and the
|
* The former left sidebar is gone: every panel (context, work, tickets, agents,
|
||||||
* known-projects list, ensuring they are always in the DOM (required by tests).
|
* 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
|
* 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
|
* **Test-contract** — the create-project form + known-projects list are in the
|
||||||
* always present in the DOM regardless of which sidebar tab is active:
|
* DOM whenever no project is active (welcome area) or when the Projects window is
|
||||||
* - getByLabelText("project name") / ("project root") — form inputs
|
* open; the project tab bar (`role="tablist"`) is always present.
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { DomainEvent, LayoutInfo } from "@/domain";
|
import type { DomainEvent, LayoutInfo } from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
@ -41,13 +40,25 @@ import { PermissionsPanel } from "@/features/permissions";
|
|||||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||||
import { TicketsView } from "@/features/tickets";
|
import { TicketsView } from "@/features/tickets";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
import { ConversationViewer } from "@/features/conversations";
|
||||||
|
import { ProfilesSettings } from "@/features/first-run";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
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 { useGateways } from "@/app/di";
|
||||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
type SidebarTab =
|
/** A panel reachable from the menu bar; each opens in a floating window. */
|
||||||
|
type PanelId =
|
||||||
| "projects"
|
| "projects"
|
||||||
| "context"
|
| "context"
|
||||||
| "work"
|
| "work"
|
||||||
@ -59,18 +70,33 @@ type SidebarTab =
|
|||||||
| "memory"
|
| "memory"
|
||||||
| "git";
|
| "git";
|
||||||
|
|
||||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
/** Human titles for each panel's floating window (also its aria-label). */
|
||||||
{ id: "projects", label: "Projects" },
|
const PANEL_TITLE: Record<PanelId, string> = {
|
||||||
{ id: "context", label: "Context" },
|
projects: "Projects",
|
||||||
{ id: "work", label: "Work" },
|
context: "Project context",
|
||||||
{ id: "tickets", label: "Tickets" },
|
work: "Work state",
|
||||||
{ id: "agents", label: "Agents" },
|
tickets: "Tickets",
|
||||||
{ id: "templates", label: "Templates" },
|
agents: "Agents",
|
||||||
{ id: "skills", label: "Skills" },
|
templates: "Templates",
|
||||||
{ id: "permissions", label: "Perms" },
|
skills: "Skills",
|
||||||
{ id: "memory", label: "Memory" },
|
permissions: "Permissions",
|
||||||
{ id: "git", label: "Git" },
|
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 {
|
interface BackgroundTaskToast {
|
||||||
id: string;
|
id: string;
|
||||||
@ -97,8 +123,12 @@ export function ProjectsView() {
|
|||||||
const { system } = useGateways();
|
const { system } = useGateways();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [root, setRoot] = useState("");
|
const [root, setRoot] = useState("");
|
||||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("projects");
|
// Which panel's floating window is open (null = none). Replaces the sidebar.
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
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
|
// 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
|
// truth. `kind` decides whether the main area is the terminal grid or the git
|
||||||
// graph view.
|
// graph view.
|
||||||
@ -166,6 +196,220 @@ export function ProjectsView() {
|
|||||||
if (picked !== null) setRoot(picked);
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* ── Error alert ── */}
|
{/* ── Error alert ── */}
|
||||||
@ -193,279 +437,96 @@ export function ProjectsView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── IDE body: sidebar + main ── */}
|
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<MenuBar menus={menus} />
|
||||||
{/* ── 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Sidebar ── */}
|
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
||||||
<aside
|
<main className="flex flex-1 flex-col overflow-hidden">
|
||||||
className={cn(
|
{showSettings ? (
|
||||||
"flex w-80 shrink-0 flex-col border-r border-border bg-surface",
|
// Top-level view switch (#16): AI Profiles settings takes over the main
|
||||||
sidebarCollapsed && "hidden",
|
// 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">
|
||||||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs).
|
<ProfilesSettings />
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
) : active && viewerConversationId ? (
|
||||||
{/* Sidebar panel body */}
|
<ConversationViewer
|
||||||
<div className="flex-1 overflow-auto p-3">
|
key={`${active.id}-${viewerConversationId}`}
|
||||||
{/* Projects panel */}
|
projectId={active.id}
|
||||||
{sidebarTab === "projects" && (
|
conversationId={viewerConversationId}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={() => setViewerConversationId(null)}
|
||||||
{/* Create form */}
|
/>
|
||||||
<form
|
) : active ? (
|
||||||
onSubmit={submit}
|
<>
|
||||||
className="flex flex-col gap-2"
|
<LayoutTabs
|
||||||
>
|
|
||||||
<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}`}
|
|
||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
conversationId={viewerConversationId}
|
onActiveLayoutChange={setActiveLayout}
|
||||||
onClose={() => setViewerConversationId(null)}
|
|
||||||
/>
|
/>
|
||||||
) : active ? (
|
{activeLayoutKind === "gitGraph" ? (
|
||||||
<>
|
<GitGraphView
|
||||||
<LayoutTabs
|
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
|
||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
onActiveLayoutChange={setActiveLayout}
|
|
||||||
/>
|
/>
|
||||||
{activeLayoutKind === "gitGraph" ? (
|
) : (
|
||||||
<GitGraphView
|
<LayoutGrid
|
||||||
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
|
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||||||
projectId={active.id}
|
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
|
<p className="text-sm text-muted">
|
||||||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
Select or create a project to get started.
|
||||||
projectId={active.id}
|
</p>
|
||||||
cwd={active.root}
|
|
||||||
layoutId={activeLayout?.id}
|
|
||||||
onOpenConversation={setViewerConversationId}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</main>
|
)}
|
||||||
</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 && (
|
{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) => (
|
{taskToasts.map((toast) => (
|
||||||
<button
|
<button
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
|
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
|
||||||
onClick={() => {
|
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);
|
if (projectOpen) vm.activateTab(toast.projectId);
|
||||||
setSidebarCollapsed(false);
|
setShowSettings(false);
|
||||||
setSidebarTab("work");
|
|
||||||
setViewerConversationId(null);
|
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">
|
<span className="block text-sm font-medium text-content">
|
||||||
|
|||||||
@ -73,6 +73,16 @@ function tablist() {
|
|||||||
return screen.getByRole("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)", () => {
|
describe("ProjectsView (with MockProjectGateway)", () => {
|
||||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
@ -85,8 +95,11 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
const tab = await screen.findByRole("tab");
|
const tab = await screen.findByRole("tab");
|
||||||
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 the root is rendered (known-projects list + active-tab panel).
|
// And it is listed in the projects manager (File → Projects…) with its root.
|
||||||
expect(screen.getAllByText("/home/me/alpha").length).toBeGreaterThan(0);
|
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 () => {
|
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/a");
|
||||||
await screen.findByText("/p/b");
|
await screen.findByText("/p/b");
|
||||||
|
|
||||||
// Open alpha, then beta.
|
// Open the projects manager window so the list persists across activation
|
||||||
const items = screen.getAllByRole("listitem");
|
// (the inline welcome list is replaced by the terminal grid once active).
|
||||||
const openA = within(
|
openPanel("File", "Projects…");
|
||||||
items.find((li) => within(li).queryByText("alpha"))!,
|
|
||||||
).getByRole("button", { name: "Open" });
|
|
||||||
fireEvent.click(openA);
|
|
||||||
|
|
||||||
|
// 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(() =>
|
await waitFor(() =>
|
||||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
const openB = within(
|
fireEvent.click(openInList("beta"));
|
||||||
items.find((li) => within(li).queryByText("beta"))!,
|
|
||||||
).getByRole("button", { name: "Open" });
|
|
||||||
fireEvent.click(openB);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
|
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
|
||||||
@ -140,11 +154,63 @@ 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" });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Work" }));
|
openPanel("View", "Work");
|
||||||
|
|
||||||
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 () => {
|
||||||
|
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 () => {
|
it("closing a tab removes it from the tab bar", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
await createProject("alpha", "/home/me/alpha");
|
await createProject("alpha", "/home/me/alpha");
|
||||||
@ -164,61 +230,67 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
await createProject("alpha", "/dup/root");
|
await createProject("alpha", "/dup/root");
|
||||||
await screen.findByRole("tab"); // first project opened as a tab
|
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");
|
await createProject("beta", "/dup/root");
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toMatch(/already exists/i);
|
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.
|
// Set up a project so that agent/template/git panels are reachable.
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
await project.createProject("alpha", "/p/a");
|
await project.createProject("alpha", "/p/a");
|
||||||
renderView(project);
|
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");
|
await screen.findByText("/p/a");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default sidebar tab is "Projects" — the form inputs must be accessible.
|
// File → Projects… opens the projects manager window — form inputs visible.
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
openPanel("File", "Projects…");
|
||||||
expect(screen.getByLabelText("project root")).toBeTruthy();
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
|
||||||
// Switch to Agents tab — agent form should be visible.
|
// View → Agents — agent form visible; project form window is replaced.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
|
openPanel("View", "Agents");
|
||||||
// Agent panel renders an "agent name" input; project form is now hidden.
|
|
||||||
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();
|
||||||
|
|
||||||
// Switch to Context tab — shared project context editor should be visible.
|
// View → Context — shared project context editor visible.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
openPanel("View", "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();
|
||||||
|
|
||||||
// Switch to Templates tab — the "New template" button should be visible.
|
// View → Templates — the "New template" button visible.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
|
openPanel("View", "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();
|
||||||
|
|
||||||
// Switch to Git tab — commit message input should be visible.
|
// View → Git — commit message input visible.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Git" }));
|
openPanel("View", "Git");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Switch back to Projects tab — form is accessible again.
|
// File → Projects… again — form accessible once more.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Projects" }));
|
openPanel("File", "Projects…");
|
||||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("project name")).toBeTruthy(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits the shared project context stored under .ideai", async () => {
|
it("edits the shared project context stored under .ideai", async () => {
|
||||||
@ -229,7 +301,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" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
openPanel("View", "Context");
|
||||||
|
|
||||||
const editor = (await screen.findByLabelText(
|
const editor = (await screen.findByLabelText(
|
||||||
"project context",
|
"project context",
|
||||||
|
|||||||
@ -34,3 +34,9 @@ export type { SpinnerProps } from "./ui/Spinner";
|
|||||||
|
|
||||||
export { zIndex } from "./ui/zIndex";
|
export { zIndex } from "./ui/zIndex";
|
||||||
export type { ZIndexLevel } from "./ui/zIndex";
|
export type { ZIndexLevel } from "./ui/zIndex";
|
||||||
|
|
||||||
|
export { FloatingWindow } from "./ui/FloatingWindow";
|
||||||
|
export type { FloatingWindowProps, FloatingWindowSize } from "./ui/FloatingWindow";
|
||||||
|
|
||||||
|
export { MenuBar } from "./ui/MenuBar";
|
||||||
|
export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
|
||||||
|
|||||||
170
frontend/src/shared/ui/FloatingWindow.test.tsx
Normal file
170
frontend/src/shared/ui/FloatingWindow.test.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { FloatingWindow } from "./FloatingWindow";
|
||||||
|
import { MenuBar } from "./MenuBar";
|
||||||
|
import { zIndex } from "./zIndex";
|
||||||
|
|
||||||
|
describe("FloatingWindow (#16, G1)", () => {
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FloatingWindow open={false} title="Panel" onClose={vi.fn()}>
|
||||||
|
<button>inside</button>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a titled modal dialog at the floatingWindow z-index", () => {
|
||||||
|
render(
|
||||||
|
<FloatingWindow open title="My panel" onClose={vi.fn()}>
|
||||||
|
<p>body</p>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole("dialog", { name: "My panel" });
|
||||||
|
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||||
|
// The backdrop carries the frozen z-index token.
|
||||||
|
const backdrop = dialog.parentElement as HTMLElement;
|
||||||
|
expect(backdrop.style.zIndex).toBe(String(zIndex.floatingWindow));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus into the window on open (focus-trap entry)", () => {
|
||||||
|
render(
|
||||||
|
<FloatingWindow open title="Panel" onClose={vi.fn()}>
|
||||||
|
<input aria-label="first field" />
|
||||||
|
<button>second</button>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
// The first focusable element (the Close button in the header) receives focus.
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("button", { name: "close Panel" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps Tab from the last focusable back to the first (focus-trap)", () => {
|
||||||
|
render(
|
||||||
|
<FloatingWindow open title="Panel" onClose={vi.fn()}>
|
||||||
|
<button>only</button>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
const closeBtn = screen.getByRole("button", { name: "close Panel" });
|
||||||
|
const onlyBtn = screen.getByRole("button", { name: "only" });
|
||||||
|
|
||||||
|
// Focus the last focusable, press Tab ⇒ wraps to the first (close button).
|
||||||
|
onlyBtn.focus();
|
||||||
|
fireEvent.keyDown(document, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(closeBtn);
|
||||||
|
|
||||||
|
// Shift+Tab from the first wraps to the last.
|
||||||
|
closeBtn.focus();
|
||||||
|
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||||
|
expect(document.activeElement).toBe(onlyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape, backdrop click, and the Close button", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<FloatingWindow open title="Panel" onClose={onClose}>
|
||||||
|
<p>body</p>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
fireEvent.mouseDown(dialog.parentElement as HTMLElement); // backdrop
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "close Panel" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not close on clicks inside the panel body", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<FloatingWindow open title="Panel" onClose={onClose}>
|
||||||
|
<button>inside</button>
|
||||||
|
</FloatingWindow>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseDown(screen.getByRole("button", { name: "inside" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "inside" }));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to the opener when closed", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)}>opener</button>
|
||||||
|
<FloatingWindow open={open} title="Panel" onClose={() => setOpen(false)}>
|
||||||
|
<p>body</p>
|
||||||
|
</FloatingWindow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const opener = screen.getByRole("button", { name: "opener" });
|
||||||
|
opener.focus();
|
||||||
|
fireEvent.click(opener);
|
||||||
|
// Window open, focus moved inside.
|
||||||
|
expect(document.activeElement).not.toBe(opener);
|
||||||
|
// Close via Escape ⇒ focus returns to the opener.
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MenuBar (#16)", () => {
|
||||||
|
const menus = [
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
label: "View",
|
||||||
|
items: [
|
||||||
|
{ id: "a", label: "Agents", onSelect: vi.fn() },
|
||||||
|
{ id: "b", label: "Git", onSelect: vi.fn() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("opens a dropdown on click and fires the item's onSelect, then closes", () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(
|
||||||
|
<MenuBar
|
||||||
|
menus={[
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
label: "View",
|
||||||
|
items: [{ id: "a", label: "Agents", onSelect }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Item hidden until the menu opens.
|
||||||
|
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "View" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||||
|
// Menu closes after selection.
|
||||||
|
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the open menu on Escape", () => {
|
||||||
|
render(<MenuBar menus={menus} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "View" }));
|
||||||
|
expect(screen.getByRole("button", { name: "Agents" })).toBeTruthy();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Agents" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the dropdown at the menuDropdown z-index", () => {
|
||||||
|
render(<MenuBar menus={menus} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "View" }));
|
||||||
|
const dropdown = screen.getByRole("menu", { name: "View" });
|
||||||
|
expect(dropdown.style.zIndex).toBe(String(zIndex.menuDropdown));
|
||||||
|
});
|
||||||
|
});
|
||||||
154
frontend/src/shared/ui/FloatingWindow.tsx
Normal file
154
frontend/src/shared/ui/FloatingWindow.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* FloatingWindow — the shared modal-overlay primitive (ticket #16, gate G1).
|
||||||
|
*
|
||||||
|
* Consolidates the `fixed inset-0 role="dialog" aria-modal` pattern that was
|
||||||
|
* hand-rolled in ~7 features. Mounted at the **chrome level** (ProjectsView/App),
|
||||||
|
* never inside LayoutGrid/LeafView (G5). It provides:
|
||||||
|
* - a focus-trap (mandatory: xterm captures keyboard focus, so a window opened
|
||||||
|
* over a terminal must steal *and* trap focus, then restore it on close),
|
||||||
|
* - Escape-to-close and backdrop-click-to-close,
|
||||||
|
* - the frozen `floatingWindow` z-index token (50).
|
||||||
|
*
|
||||||
|
* Purely presentational: the caller owns `open`/`onClose` state and the body.
|
||||||
|
* Rendered as `null` while closed so the body (and its hooks) only run when the
|
||||||
|
* window is actually open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { zIndex } from "./zIndex";
|
||||||
|
|
||||||
|
/** Width presets; height is capped at 80vh with an internal scroll region. */
|
||||||
|
export type FloatingWindowSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
const SIZE_CLASS: Record<FloatingWindowSize, string> = {
|
||||||
|
sm: "max-w-sm",
|
||||||
|
md: "max-w-lg",
|
||||||
|
lg: "max-w-2xl",
|
||||||
|
xl: "max-w-4xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tabbable-element selector used by the focus-trap. */
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export interface FloatingWindowProps {
|
||||||
|
/** Whether the window is shown. Rendered as `null` when closed. */
|
||||||
|
open: boolean;
|
||||||
|
/** Accessible dialog title, shown in the header. */
|
||||||
|
title: string;
|
||||||
|
/** Called on Escape, backdrop click, or the Close button. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Width preset (default "md"). */
|
||||||
|
size?: FloatingWindowSize;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingWindow({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
size = "md",
|
||||||
|
children,
|
||||||
|
}: FloatingWindowProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<FloatingWindowBody title={title} onClose={onClose} size={size}>
|
||||||
|
{children}
|
||||||
|
</FloatingWindowBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mounted window. Split out so the focus-trap effect and the body only run
|
||||||
|
* while `open` — mounting/unmounting on `open` gives clean focus save/restore.
|
||||||
|
*/
|
||||||
|
function FloatingWindowBody({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
}: Omit<FloatingWindowProps, "open">) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
const node = dialogRef.current;
|
||||||
|
// Move focus into the window (first focusable, else the dialog itself).
|
||||||
|
const first = node?.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
(first ?? node)?.focus();
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !node) return;
|
||||||
|
const focusables = Array.from(
|
||||||
|
node.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||||
|
);
|
||||||
|
if (focusables.length === 0) {
|
||||||
|
// Nothing focusable inside: keep focus on the dialog container.
|
||||||
|
e.preventDefault();
|
||||||
|
node.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstEl = focusables[0];
|
||||||
|
const lastEl = focusables[focusables.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (e.shiftKey && active === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
} else if (!e.shiftKey && active === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
previouslyFocused?.focus?.();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
||||||
|
style={{ zIndex: zIndex.floatingWindow }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Backdrop click closes; clicks inside the panel don't reach here.
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
"flex max-h-[80vh] w-full flex-col overflow-hidden rounded-lg",
|
||||||
|
"border border-border bg-surface shadow-xl outline-none",
|
||||||
|
SIZE_CLASS[size ?? "md"],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-content">{title}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`close ${title}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/shared/ui/MenuBar.tsx
Normal file
126
frontend/src/shared/ui/MenuBar.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* MenuBar — a top-of-window bar of dropdown menus (ticket #16).
|
||||||
|
*
|
||||||
|
* Replaces the former left sidebar tab strip with a classic IDE-style menu bar:
|
||||||
|
* each top-level menu opens a dropdown of items; an item runs its `onSelect`
|
||||||
|
* (typically opening a {@link FloatingWindow} or toggling a panel) and closes
|
||||||
|
* the menu. One menu is open at a time; Escape or an outside click closes it.
|
||||||
|
*
|
||||||
|
* Dropdowns use the frozen `menuDropdown` z-index token (40). Menu items are
|
||||||
|
* plain `<button>`s (implicit `role="button"`) so callers/tests can target them
|
||||||
|
* by accessible name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
import { zIndex } from "./zIndex";
|
||||||
|
|
||||||
|
export interface MenuBarItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Renders a selected/active marker (e.g. the currently-open panel). */
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuBarMenu {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
items: MenuBarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuBarProps {
|
||||||
|
menus: MenuBarMenu[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuBar({ menus, className }: MenuBarProps) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(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);
|
||||||
|
}
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpenId(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [openId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-0.5 border-b border-border bg-surface px-2 py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menus.map((menu) => {
|
||||||
|
const open = openId === menu.id;
|
||||||
|
return (
|
||||||
|
<div key={menu.id} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpenId((cur) => (cur === menu.id ? null : menu.id))}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2.5 py-1 text-sm transition-colors",
|
||||||
|
open
|
||||||
|
? "bg-raised text-content"
|
||||||
|
: "text-muted hover:bg-raised hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menu.label}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={menu.label}
|
||||||
|
style={{ zIndex: zIndex.menuDropdown }}
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-full mt-1 min-w-44 rounded-md border border-border",
|
||||||
|
"bg-surface py-1 shadow-xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menu.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.active && (
|
||||||
|
<span aria-hidden className="text-primary">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,11 +6,10 @@
|
|||||||
* mounted at the **chrome level** (ProjectsView/App), never inside
|
* mounted at the **chrome level** (ProjectsView/App), never inside
|
||||||
* LayoutGrid/LeafView (G5).
|
* LayoutGrid/LeafView (G5).
|
||||||
*
|
*
|
||||||
* ⚠️ PROVISIONAL (lot B / ticket #18): created here because lot A (ticket #16)
|
* Consumers: `FloatingWindow` (floatingWindow), `MenuBar` dropdowns
|
||||||
* introduces the canonical `shared/ui/zIndex.ts` (+ `FloatingWindow`) in
|
* (menuDropdown), `TicketPicker` (floatingWindowNested), background-task toasts
|
||||||
* parallel. The values below MUST match the frozen scale; at merge, keep a
|
* (toast). Introduced by lot B (#18) and adopted as the canonical scale by lot A
|
||||||
* single module (drop this one if lot A's is richer). See the sprint scoping
|
* (#16). See the sprint scoping note `ui-rework-sprint-scoping-contracts`.
|
||||||
* note `ui-rework-sprint-scoping-contracts`.
|
|
||||||
*/
|
*/
|
||||||
export const zIndex = {
|
export const zIndex = {
|
||||||
/** Dropdown menus / popovers anchored to a control. */
|
/** Dropdown menus / popovers anchored to a control. */
|
||||||
|
|||||||
Reference in New Issue
Block a user