From 419e9b8498a12bf625d36f070b78fe330f8bcad1 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 11:45:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20anchor=20de=20views=20=E2=80=94=20p?= =?UTF-8?q?rimitive=20de=20docking=20DockRegion=20+=20mod=C3=A8le=20ViewPl?= =?UTF-8?q?acement=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../projects/ProjectsView.docking.test.tsx | 122 +++++++++ .../src/features/projects/ProjectsView.tsx | 246 ++++++++++++++---- .../src/features/projects/viewPlacement.ts | 87 +++++++ frontend/src/shared/index.ts | 3 + frontend/src/shared/ui/DockRegion.test.tsx | 59 +++++ frontend/src/shared/ui/DockRegion.tsx | 125 +++++++++ 6 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 frontend/src/features/projects/ProjectsView.docking.test.tsx create mode 100644 frontend/src/features/projects/viewPlacement.ts create mode 100644 frontend/src/shared/ui/DockRegion.test.tsx create mode 100644 frontend/src/shared/ui/DockRegion.tsx diff --git a/frontend/src/features/projects/ProjectsView.docking.test.tsx b/frontend/src/features/projects/ProjectsView.docking.test.tsx new file mode 100644 index 0000000..b53f6be --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.docking.test.tsx @@ -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( + + + , + ); + // 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(); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index ed5ff68..e274730 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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 = { - 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 = { @@ -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(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({}); + // 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 ( +
+ + + + +
+ ); + } + + // 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 ( +
+
+ + {PANEL_TITLE[panel]} + + +
+
+ {renderPanel(panel)} +
+
+ ); + } return (
@@ -440,8 +556,22 @@ export function ProjectsView() { {/* ── Menu bar (replaces the former left sidebar) ── */} - {/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */} -
+ {/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow + resizable columns, not overlays — they sit beside the main surface. */} +
+ {leftPanels.length > 0 && ( + + {leftPanels.map(renderDockedView)} + + )} + + {/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */} +
{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() {
)} - + - {/* ── Panel floating window ── */} - {openPanel && ( + {rightPanels.length > 0 && ( + + {rightPanels.map(renderDockedView)} + + )} + + + {/* ── Floating panel windows (modal overlays) ── */} + {floatingList.map((panel) => ( setOpenPanel(null)} + title={PANEL_TITLE[panel]} + size={PANEL_SIZE[panel]} + onClose={() => closePanel(panel)} > - {renderPanel(openPanel)} +
+ +
+ {renderPanel(panel)}
- )} + ))} {/* ── 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), ); diff --git a/frontend/src/features/projects/viewPlacement.ts b/frontend/src/features/projects/viewPlacement.ts new file mode 100644 index 0000000..a05b114 --- /dev/null +++ b/frontend/src/features/projects/viewPlacement.ts @@ -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>; + +/** Human titles for each panel (window title, dock header, aria-label). */ +export const PANEL_TITLE: Record = { + 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", + ); +} diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index b410e6d..3e95840 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -40,3 +40,6 @@ export type { FloatingWindowProps, FloatingWindowSize } from "./ui/FloatingWindo export { MenuBar } from "./ui/MenuBar"; export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar"; + +export { DockRegion } from "./ui/DockRegion"; +export type { DockRegionProps, DockSide } from "./ui/DockRegion"; diff --git a/frontend/src/shared/ui/DockRegion.test.tsx b/frontend/src/shared/ui/DockRegion.test.tsx new file mode 100644 index 0000000..181dd46 --- /dev/null +++ b/frontend/src/shared/ui/DockRegion.test.tsx @@ -0,0 +1,59 @@ +/** + * DockRegion (#22) — the in-flow resizable side column. Covers: it renders its + * children beside a resize handle, the handle carries the right a11y role, and a + * pointer drag maps to a clamped width delta whose sign depends on the side + * (left grows moving right, right grows moving left). + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { DockRegion } from "./DockRegion"; + +describe("DockRegion", () => { + it("renders its children and an inner-edge resize separator", () => { + render( + {}}> +

docked body

+
, + ); + expect(screen.getByText("docked body")).toBeTruthy(); + const handle = screen.getByRole("separator", { name: "resize left dock" }); + expect(handle.getAttribute("aria-orientation")).toBe("vertical"); + }); + + it("a left dock grows when the pointer drags right", () => { + const onResize = vi.fn(); + render( + +

body

+
, + ); + const handle = screen.getByRole("separator", { name: "resize left dock" }); + fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 160 }); + expect(onResize).toHaveBeenLastCalledWith(360); + }); + + it("a right dock grows when the pointer drags left, and width is clamped", () => { + const onResize = vi.fn(); + render( + +

body

+
, + ); + const handle = screen.getByRole("separator", { name: "resize right dock" }); + // Dragging left (negative delta) grows a right dock. + fireEvent.pointerDown(handle, { pointerId: 1, clientX: 400 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 320 }); + expect(onResize).toHaveBeenLastCalledWith(380); + // A large leftward drag is clamped to maxWidth. + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 0 }); + expect(onResize).toHaveBeenLastCalledWith(500); + }); +}); diff --git a/frontend/src/shared/ui/DockRegion.tsx b/frontend/src/shared/ui/DockRegion.tsx new file mode 100644 index 0000000..887a92d --- /dev/null +++ b/frontend/src/shared/ui/DockRegion.tsx @@ -0,0 +1,125 @@ +/** + * DockRegion — a resizable, **in-flow** vertical column docked to the left or + * right of the main area (ticket #22, gate G5). + * + * Unlike {@link FloatingWindow} (a modal overlay), a DockRegion is *not* an + * overlay: it lives in the normal flex flow beside `
`, so docked views and + * the terminal grid are visible at once (VS Code / JetBrains side-panel feel). + * It therefore needs no z-index token — it never stacks over the main surface. + * + * Purely presentational: it owns only the drag mechanics of its width. The + * caller controls `width` and commits new values via `onResize`, and supplies + * the docked view(s) as children (each typically its own header + body). The + * resize handle sits on the column's *inner* edge — the right edge for a + * left-docked column, the left edge for a right-docked one — so dragging toward + * the main area shrinks the column and away from it grows the column. + * + * Mounted at the chrome level (ProjectsView), never inside LayoutGrid/LeafView. + */ + +import { useRef, type ReactNode } from "react"; + +import { cn } from "../lib/cn"; + +/** Which side of `
` the column is docked to. */ +export type DockSide = "left" | "right"; + +/** Default clamp for the column width, in CSS pixels. */ +const DEFAULT_MIN_WIDTH = 240; +const DEFAULT_MAX_WIDTH = 720; + +export interface DockRegionProps { + /** Side of the main area this column is docked to. */ + side: DockSide; + /** Current column width in pixels (controlled by the caller). */ + width: number; + /** Called live during the drag and on release with the clamped next width. */ + onResize: (nextWidth: number) => void; + /** Minimum width in pixels (default 240). */ + minWidth?: number; + /** Maximum width in pixels (default 720). */ + maxWidth?: number; + /** Accessible label for the column region. */ + "aria-label"?: string; + /** The docked view(s), stacked vertically. */ + children: ReactNode; +} + +/** + * A resizable docked column. The width is driven from a pointer drag on the + * inner-edge handle, reported back through `onResize`; the caller holds the + * value in state (ephemeral in V1 — persistence is a deferred follow-up). + */ +export function DockRegion({ + side, + width, + onResize, + minWidth = DEFAULT_MIN_WIDTH, + maxWidth = DEFAULT_MAX_WIDTH, + "aria-label": ariaLabel, + children, +}: DockRegionProps) { + // Anchor the drag on pointer-down: remember where the pointer and the column + // width started, then map the signed pointer delta to a width delta. A + // left-docked column grows as the pointer moves right; a right-docked one + // grows as it moves left (the handle is on the opposite, inner edge). + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); + + function clamp(next: number): number { + return Math.min(maxWidth, Math.max(minWidth, next)); + } + + function onPointerDown(e: React.PointerEvent) { + e.preventDefault(); + // `setPointerCapture` is absent under jsdom; guard so tests can drive drags. + e.currentTarget.setPointerCapture?.(e.pointerId); + dragRef.current = { startX: e.clientX, startWidth: width }; + } + function onPointerMove(e: React.PointerEvent) { + const drag = dragRef.current; + if (!drag) return; + const delta = e.clientX - drag.startX; + const signed = side === "left" ? delta : -delta; + onResize(clamp(drag.startWidth + signed)); + } + function onPointerUp(e: React.PointerEvent) { + if (!dragRef.current) return; + const delta = e.clientX - dragRef.current.startX; + const signed = side === "left" ? delta : -delta; + dragRef.current = null; + onResize(clamp(width + signed)); + } + + const handle = ( +
+ ); + + return ( + + ); +}