feat(ui): anchor de views — primitive de docking DockRegion + modèle ViewPlacement (#22)
Introduit la primitive de docking DockRegion et le modèle ViewPlacement pour ancrer les vues dans le chrome, câblés dans ProjectsView. Tests unitaires DockRegion et test d'intégration docking de ProjectsView. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
122
frontend/src/features/projects/ProjectsView.docking.test.tsx
Normal file
122
frontend/src/features/projects/ProjectsView.docking.test.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* #22 — view anchoring/docking in `ProjectsView`. A view opened from the menu
|
||||
* starts floating (modal window); its header controls move it into the in-flow
|
||||
* left/right {@link DockRegion} and back, and the one-slot-per-view invariant
|
||||
* holds (docking a floating view removes the modal window).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockGitGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
async function renderWithProject() {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project,
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git: new MockGitGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
);
|
||||
// Open the seeded project so panels have an active project to render for.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
await waitFor(() =>
|
||||
expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens a menu-bar dropdown item. */
|
||||
function openMenuItem(menu: string, item: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: menu }));
|
||||
fireEvent.click(screen.getByRole("button", { name: item }));
|
||||
}
|
||||
|
||||
describe("ProjectsView view docking (#22)", () => {
|
||||
it("opens a view floating, then docks it left as an in-flow column", async () => {
|
||||
await renderWithProject();
|
||||
|
||||
// View → Git opens a modal floating window (historical behaviour).
|
||||
openMenuItem("View", "Git");
|
||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
||||
expect(dialog).toBeTruthy();
|
||||
|
||||
// Dock it to the left via the header control.
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
|
||||
|
||||
// The modal window is gone; the Git view now lives in the left dock column.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Git" })).toBeNull(),
|
||||
);
|
||||
const leftDock = screen.getByRole("complementary", { name: "left dock" });
|
||||
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
// The main surface is still present beside the dock (in-flow, not overlay).
|
||||
expect(screen.getByRole("main")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps one slot per view: docking right moves it off the left dock", async () => {
|
||||
await renderWithProject();
|
||||
|
||||
openMenuItem("View", "Git");
|
||||
const dialog = await screen.findByRole("dialog", { name: "Git" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "dock Git left" }));
|
||||
|
||||
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
|
||||
const leftHeader = within(leftDock).getByLabelText("Git panel");
|
||||
// Move it to the right dock from the docked header control.
|
||||
fireEvent.click(
|
||||
within(leftHeader).getByRole("button", { name: "dock Git right" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("complementary", { name: "left dock" })).toBeNull(),
|
||||
);
|
||||
const rightDock = screen.getByRole("complementary", { name: "right dock" });
|
||||
expect(within(rightDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("docks two different views side by side (left + right)", async () => {
|
||||
await renderWithProject();
|
||||
|
||||
// Dock Git left.
|
||||
openMenuItem("View", "Git");
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole("dialog", { name: "Git" })).getByRole(
|
||||
"button",
|
||||
{ name: "dock Git left" },
|
||||
),
|
||||
);
|
||||
// Dock Agents right.
|
||||
openMenuItem("View", "Agents");
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole("dialog", { name: "Agents" })).getByRole(
|
||||
"button",
|
||||
{ name: "dock Agents right" },
|
||||
),
|
||||
);
|
||||
|
||||
const leftDock = await screen.findByRole("complementary", { name: "left dock" });
|
||||
const rightDock = await screen.findByRole("complementary", { name: "right dock" });
|
||||
expect(within(leftDock).getByLabelText("Git panel")).toBeTruthy();
|
||||
expect(within(rightDock).getByLabelText("Agents panel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -44,6 +44,7 @@ import { ProfilesSettings } from "@/features/first-run";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import {
|
||||
Button,
|
||||
DockRegion,
|
||||
FloatingWindow,
|
||||
Input,
|
||||
MenuBar,
|
||||
@ -56,33 +57,16 @@ import {
|
||||
import { useGateways } from "@/app/di";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import { useProjects } from "./useProjects";
|
||||
|
||||
/** A panel reachable from the menu bar; each opens in a floating window. */
|
||||
type PanelId =
|
||||
| "projects"
|
||||
| "context"
|
||||
| "work"
|
||||
| "tickets"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "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",
|
||||
};
|
||||
import {
|
||||
PANEL_TITLE,
|
||||
floatingPanels,
|
||||
isDockedTo,
|
||||
panelsDockedTo,
|
||||
placementOf,
|
||||
type PanelId,
|
||||
type ViewPlacement,
|
||||
type ViewPlacements,
|
||||
} from "./viewPlacement";
|
||||
|
||||
/** Window width preset per panel (content-heavy panels get more room). */
|
||||
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
||||
@ -123,8 +107,14 @@ export function ProjectsView() {
|
||||
const { system } = useGateways();
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
// Which panel's floating window is open (null = none). Replaces the sidebar.
|
||||
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
|
||||
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||
// "floating" (modal window), or docked left/right. One view = exactly one
|
||||
// slot. Ephemeral local state in V1 — restore-at-restart is a deferred
|
||||
// backend follow-up.
|
||||
const [placements, setPlacements] = useState<ViewPlacements>({});
|
||||
// Width (px) of each dock column, driven by the DockRegion resize handle.
|
||||
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
||||
const [rightDockWidth, setRightDockWidth] = useState(340);
|
||||
// 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.
|
||||
@ -196,11 +186,62 @@ 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).
|
||||
// ── View placement (#22) ────────────────────────────────────────────────
|
||||
// Move a view to a specific slot (floating or docked). Enforces the
|
||||
// one-slot-per-view invariant implicitly (a panel has a single entry) and the
|
||||
// floating surface stays single-window (the #16 modal contract): promoting a
|
||||
// view to floating demotes any other floating view to closed. Docks hold as
|
||||
// many views as fit — that's the multi-view surface.
|
||||
function setPlacement(panel: PanelId, placement: ViewPlacement) {
|
||||
setPlacements((prev) => {
|
||||
const next: ViewPlacements = { ...prev };
|
||||
if (placement === "floating") {
|
||||
for (const other of Object.keys(next) as PanelId[]) {
|
||||
if (other !== panel && next[other] === "floating") delete next[other];
|
||||
}
|
||||
}
|
||||
next[panel] = placement;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Close a view (remove its slot → "closed").
|
||||
function closePanel(panel: PanelId) {
|
||||
setPlacements((prev) => {
|
||||
if (!(panel in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[panel];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Menu toggle: open floating when closed, otherwise close (whatever the slot).
|
||||
function toggleFloating(panel: PanelId) {
|
||||
if (placementOf(placements, panel) === "closed") {
|
||||
setPlacement(panel, "floating");
|
||||
} else {
|
||||
closePanel(panel);
|
||||
}
|
||||
}
|
||||
// Dismiss every floating view (docked views stay put). Used when a full-main
|
||||
// surface takes over so a modal window doesn't obscure it.
|
||||
function dismissFloating() {
|
||||
setPlacements((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
for (const panel of Object.keys(next) as PanelId[]) {
|
||||
if (next[panel] === "floating") {
|
||||
delete next[panel];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Opening a conversation viewer takes over the main area — dismiss floating
|
||||
// windows so they don't obscure the viewer (LS7). Docked views stay beside it.
|
||||
function openConversation(conversationId: string) {
|
||||
setViewerConversationId(conversationId);
|
||||
setOpenPanel(null);
|
||||
dismissFloating();
|
||||
}
|
||||
|
||||
// Switch the active project from the always-visible selector: activate its
|
||||
@ -259,8 +300,8 @@ export function ProjectsView() {
|
||||
{
|
||||
id: "projects",
|
||||
label: "Projects…",
|
||||
active: openPanel === "projects",
|
||||
onSelect: () => setOpenPanel("projects"),
|
||||
active: placementOf(placements, "projects") !== "closed",
|
||||
onSelect: () => toggleFloating("projects"),
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -270,8 +311,8 @@ export function ProjectsView() {
|
||||
items: viewItems.map((it) => ({
|
||||
id: it.id,
|
||||
label: it.label,
|
||||
active: openPanel === it.id,
|
||||
onSelect: () => setOpenPanel(it.id),
|
||||
active: placementOf(placements, it.id) !== "closed",
|
||||
onSelect: () => toggleFloating(it.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@ -284,7 +325,7 @@ export function ProjectsView() {
|
||||
active: showSettings,
|
||||
onSelect: () => {
|
||||
setShowSettings((v) => !v);
|
||||
setOpenPanel(null);
|
||||
dismissFloating();
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -407,8 +448,83 @@ export function ProjectsView() {
|
||||
}
|
||||
|
||||
// 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";
|
||||
// already shown as a panel (avoids duplicate form inputs).
|
||||
const showInlineProjects =
|
||||
!active && placementOf(placements, "projects") === "closed";
|
||||
|
||||
// Views by slot, in stable declaration order.
|
||||
const leftPanels = panelsDockedTo(placements, "left");
|
||||
const rightPanels = panelsDockedTo(placements, "right");
|
||||
const floatingList = floatingPanels(placements);
|
||||
|
||||
// Placement toggle buttons for a view's header — the "flottant ↔ ancré
|
||||
// (gauche/droite)" control from the contract. The button for the current slot
|
||||
// is disabled so the active placement reads at a glance.
|
||||
function DockControls({ panel }: { panel: PanelId }) {
|
||||
const placement = placementOf(placements, panel);
|
||||
const title = PANEL_TITLE[panel];
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`dock ${title} left`}
|
||||
disabled={isDockedTo(placement, "left")}
|
||||
onClick={() => setPlacement(panel, { dock: "left" })}
|
||||
>
|
||||
◧
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`dock ${title} right`}
|
||||
disabled={isDockedTo(placement, "right")}
|
||||
onClick={() => setPlacement(panel, { dock: "right" })}
|
||||
>
|
||||
◨
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`float ${title}`}
|
||||
disabled={placement === "floating"}
|
||||
onClick={() => setPlacement(panel, "floating")}
|
||||
>
|
||||
⧉
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`close ${title}`}
|
||||
onClick={() => closePanel(panel)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single docked view inside a DockRegion column: header (title + placement
|
||||
// controls) over the existing view component, reused as-is.
|
||||
function renderDockedView(panel: PanelId): ReactNode {
|
||||
return (
|
||||
<section
|
||||
key={panel}
|
||||
aria-label={`${PANEL_TITLE[panel]} panel`}
|
||||
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-sm font-medium text-content">
|
||||
{PANEL_TITLE[panel]}
|
||||
</span>
|
||||
<DockControls panel={panel} />
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{renderPanel(panel)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@ -440,8 +556,22 @@ export function ProjectsView() {
|
||||
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
||||
<MenuBar menus={menus} />
|
||||
|
||||
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow
|
||||
resizable columns, not overlays — they sit beside the main surface. */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{leftPanels.length > 0 && (
|
||||
<DockRegion
|
||||
side="left"
|
||||
width={leftDockWidth}
|
||||
onResize={setLeftDockWidth}
|
||||
aria-label="left dock"
|
||||
>
|
||||
{leftPanels.map(renderDockedView)}
|
||||
</DockRegion>
|
||||
)}
|
||||
|
||||
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
||||
<main className="flex min-w-0 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.
|
||||
@ -491,19 +621,35 @@ export function ProjectsView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</main>
|
||||
|
||||
{/* ── Panel floating window ── */}
|
||||
{openPanel && (
|
||||
{rightPanels.length > 0 && (
|
||||
<DockRegion
|
||||
side="right"
|
||||
width={rightDockWidth}
|
||||
onResize={setRightDockWidth}
|
||||
aria-label="right dock"
|
||||
>
|
||||
{rightPanels.map(renderDockedView)}
|
||||
</DockRegion>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Floating panel windows (modal overlays) ── */}
|
||||
{floatingList.map((panel) => (
|
||||
<FloatingWindow
|
||||
key={panel}
|
||||
open
|
||||
title={PANEL_TITLE[openPanel]}
|
||||
size={PANEL_SIZE[openPanel]}
|
||||
onClose={() => setOpenPanel(null)}
|
||||
title={PANEL_TITLE[panel]}
|
||||
size={PANEL_SIZE[panel]}
|
||||
onClose={() => closePanel(panel)}
|
||||
>
|
||||
{renderPanel(openPanel)}
|
||||
<div className="mb-3 flex justify-end border-b border-border pb-3">
|
||||
<DockControls panel={panel} />
|
||||
</div>
|
||||
{renderPanel(panel)}
|
||||
</FloatingWindow>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* ── Background-task toasts (above floating windows) ── */}
|
||||
{taskToasts.length > 0 && (
|
||||
@ -523,7 +669,7 @@ export function ProjectsView() {
|
||||
if (projectOpen) vm.activateTab(toast.projectId);
|
||||
setShowSettings(false);
|
||||
setViewerConversationId(null);
|
||||
setOpenPanel("work");
|
||||
setPlacement("work", "floating");
|
||||
setTaskToasts((prev) =>
|
||||
prev.filter((item) => item.id !== toast.id),
|
||||
);
|
||||
|
||||
87
frontend/src/features/projects/viewPlacement.ts
Normal file
87
frontend/src/features/projects/viewPlacement.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* View placement model (ticket #22) — generalises the former single
|
||||
* `openPanel: PanelId | null` state into a per-view *slot* assignment.
|
||||
*
|
||||
* Each view (context/work/tickets/agents/…) is in exactly one placement at a
|
||||
* time — the core invariant. V1 supports:
|
||||
* - `"closed"` — not shown anywhere,
|
||||
* - `"floating"` — shown in a modal {@link FloatingWindow} (the historical
|
||||
* behaviour),
|
||||
* - `{ dock }` — anchored in-flow in the left/right {@link DockRegion}.
|
||||
*
|
||||
* The union is **deliberately extensible**: ticket #23 adds `"detached"` (an OS
|
||||
* window) as a further mutually-exclusive slot without touching the invariant.
|
||||
* Absent from the map ≡ `"closed"`, so the default (nothing open) is the empty
|
||||
* map.
|
||||
*/
|
||||
|
||||
import type { DockSide } from "@/shared";
|
||||
|
||||
/** A panel reachable from the menu bar. */
|
||||
export type PanelId =
|
||||
| "projects"
|
||||
| "context"
|
||||
| "work"
|
||||
| "tickets"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git";
|
||||
|
||||
/**
|
||||
* Where a view currently lives. Exactly one slot per view (the invariant).
|
||||
* `{ dock }` carries the side; #23 will extend this union with `"detached"`.
|
||||
*/
|
||||
export type ViewPlacement = "closed" | "floating" | { dock: DockSide };
|
||||
|
||||
/** The placement of every open view (absent key ≡ `"closed"`). */
|
||||
export type ViewPlacements = Partial<Record<PanelId, ViewPlacement>>;
|
||||
|
||||
/** Human titles for each panel (window title, dock header, aria-label). */
|
||||
export 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",
|
||||
};
|
||||
|
||||
/** Resolve a view's placement, treating an absent entry as `"closed"`. */
|
||||
export function placementOf(
|
||||
placements: ViewPlacements,
|
||||
panel: PanelId,
|
||||
): ViewPlacement {
|
||||
return placements[panel] ?? "closed";
|
||||
}
|
||||
|
||||
/** True when the view is docked to the given side. */
|
||||
export function isDockedTo(
|
||||
placement: ViewPlacement,
|
||||
side: DockSide,
|
||||
): boolean {
|
||||
return typeof placement === "object" && placement.dock === side;
|
||||
}
|
||||
|
||||
/** Every panel currently docked to `side`, in stable declaration order. */
|
||||
export function panelsDockedTo(
|
||||
placements: ViewPlacements,
|
||||
side: DockSide,
|
||||
): PanelId[] {
|
||||
return (Object.keys(PANEL_TITLE) as PanelId[]).filter((panel) =>
|
||||
isDockedTo(placementOf(placements, panel), side),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every panel currently floating, in stable declaration order. */
|
||||
export function floatingPanels(placements: ViewPlacements): PanelId[] {
|
||||
return (Object.keys(PANEL_TITLE) as PanelId[]).filter(
|
||||
(panel) => placementOf(placements, panel) === "floating",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user