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 ( + + ); +}