Merge feature/ticket22-view-anchor-docking into develop (#22)
Anchor de views — primitive de docking DockRegion + modèle ViewPlacement, câblés dans ProjectsView. QA vert : frontend typecheck + vitest 537/537. 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 { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
DockRegion,
|
||||||
FloatingWindow,
|
FloatingWindow,
|
||||||
Input,
|
Input,
|
||||||
MenuBar,
|
MenuBar,
|
||||||
@ -56,33 +57,16 @@ import {
|
|||||||
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";
|
||||||
|
import {
|
||||||
/** A panel reachable from the menu bar; each opens in a floating window. */
|
PANEL_TITLE,
|
||||||
type PanelId =
|
floatingPanels,
|
||||||
| "projects"
|
isDockedTo,
|
||||||
| "context"
|
panelsDockedTo,
|
||||||
| "work"
|
placementOf,
|
||||||
| "tickets"
|
type PanelId,
|
||||||
| "agents"
|
type ViewPlacement,
|
||||||
| "templates"
|
type ViewPlacements,
|
||||||
| "skills"
|
} from "./viewPlacement";
|
||||||
| "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",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Window width preset per panel (content-heavy panels get more room). */
|
/** Window width preset per panel (content-heavy panels get more room). */
|
||||||
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
||||||
@ -123,8 +107,14 @@ 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("");
|
||||||
// Which panel's floating window is open (null = none). Replaces the sidebar.
|
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||||
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
|
// "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
|
// 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
|
// settings instead of the project surface. The single menu bar stays visible
|
||||||
// so the user can toggle back from Settings → AI Profiles.
|
// so the user can toggle back from Settings → AI Profiles.
|
||||||
@ -196,11 +186,62 @@ 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
|
// ── View placement (#22) ────────────────────────────────────────────────
|
||||||
// window so it doesn't obscure the viewer (LS7).
|
// 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) {
|
function openConversation(conversationId: string) {
|
||||||
setViewerConversationId(conversationId);
|
setViewerConversationId(conversationId);
|
||||||
setOpenPanel(null);
|
dismissFloating();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch the active project from the always-visible selector: activate its
|
// Switch the active project from the always-visible selector: activate its
|
||||||
@ -259,8 +300,8 @@ export function ProjectsView() {
|
|||||||
{
|
{
|
||||||
id: "projects",
|
id: "projects",
|
||||||
label: "Projects…",
|
label: "Projects…",
|
||||||
active: openPanel === "projects",
|
active: placementOf(placements, "projects") !== "closed",
|
||||||
onSelect: () => setOpenPanel("projects"),
|
onSelect: () => toggleFloating("projects"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -270,8 +311,8 @@ export function ProjectsView() {
|
|||||||
items: viewItems.map((it) => ({
|
items: viewItems.map((it) => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
label: it.label,
|
label: it.label,
|
||||||
active: openPanel === it.id,
|
active: placementOf(placements, it.id) !== "closed",
|
||||||
onSelect: () => setOpenPanel(it.id),
|
onSelect: () => toggleFloating(it.id),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -284,7 +325,7 @@ export function ProjectsView() {
|
|||||||
active: showSettings,
|
active: showSettings,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setShowSettings((v) => !v);
|
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
|
// Projects manager renders inline in the welcome area only when it is not
|
||||||
// already shown in the floating window (avoids duplicate form inputs).
|
// already shown as a panel (avoids duplicate form inputs).
|
||||||
const showInlineProjects = !active && openPanel !== "projects";
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
@ -440,8 +556,22 @@ export function ProjectsView() {
|
|||||||
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
{/* ── Menu bar (replaces the former left sidebar) ── */}
|
||||||
<MenuBar menus={menus} />
|
<MenuBar menus={menus} />
|
||||||
|
|
||||||
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
|
{/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow
|
||||||
<main className="flex flex-1 flex-col overflow-hidden">
|
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 ? (
|
{showSettings ? (
|
||||||
// Top-level view switch (#16): AI Profiles settings takes over the main
|
// Top-level view switch (#16): AI Profiles settings takes over the main
|
||||||
// area while the menu bar above stays visible to toggle back.
|
// area while the menu bar above stays visible to toggle back.
|
||||||
@ -491,19 +621,35 @@ export function ProjectsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Panel floating window ── */}
|
{rightPanels.length > 0 && (
|
||||||
{openPanel && (
|
<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
|
<FloatingWindow
|
||||||
|
key={panel}
|
||||||
open
|
open
|
||||||
title={PANEL_TITLE[openPanel]}
|
title={PANEL_TITLE[panel]}
|
||||||
size={PANEL_SIZE[openPanel]}
|
size={PANEL_SIZE[panel]}
|
||||||
onClose={() => setOpenPanel(null)}
|
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>
|
</FloatingWindow>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* ── Background-task toasts (above floating windows) ── */}
|
{/* ── Background-task toasts (above floating windows) ── */}
|
||||||
{taskToasts.length > 0 && (
|
{taskToasts.length > 0 && (
|
||||||
@ -523,7 +669,7 @@ export function ProjectsView() {
|
|||||||
if (projectOpen) vm.activateTab(toast.projectId);
|
if (projectOpen) vm.activateTab(toast.projectId);
|
||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
setViewerConversationId(null);
|
setViewerConversationId(null);
|
||||||
setOpenPanel("work");
|
setPlacement("work", "floating");
|
||||||
setTaskToasts((prev) =>
|
setTaskToasts((prev) =>
|
||||||
prev.filter((item) => item.id !== toast.id),
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -40,3 +40,6 @@ export type { FloatingWindowProps, FloatingWindowSize } from "./ui/FloatingWindo
|
|||||||
|
|
||||||
export { MenuBar } from "./ui/MenuBar";
|
export { MenuBar } from "./ui/MenuBar";
|
||||||
export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
|
export type { MenuBarProps, MenuBarMenu, MenuBarItem } from "./ui/MenuBar";
|
||||||
|
|
||||||
|
export { DockRegion } from "./ui/DockRegion";
|
||||||
|
export type { DockRegionProps, DockSide } from "./ui/DockRegion";
|
||||||
|
|||||||
59
frontend/src/shared/ui/DockRegion.test.tsx
Normal file
59
frontend/src/shared/ui/DockRegion.test.tsx
Normal file
@ -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(
|
||||||
|
<DockRegion side="left" width={300} onResize={() => {}}>
|
||||||
|
<p>docked body</p>
|
||||||
|
</DockRegion>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DockRegion side="left" width={300} onResize={onResize}>
|
||||||
|
<p>body</p>
|
||||||
|
</DockRegion>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DockRegion
|
||||||
|
side="right"
|
||||||
|
width={300}
|
||||||
|
onResize={onResize}
|
||||||
|
minWidth={240}
|
||||||
|
maxWidth={500}
|
||||||
|
>
|
||||||
|
<p>body</p>
|
||||||
|
</DockRegion>,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
frontend/src/shared/ui/DockRegion.tsx
Normal file
125
frontend/src/shared/ui/DockRegion.tsx
Normal file
@ -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 `<main>`, 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 `<main>` 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<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
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 = (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={`resize ${side} dock`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
className="shrink-0 cursor-col-resize bg-border hover:bg-border-strong"
|
||||||
|
style={{ flex: "0 0 4px", touchAction: "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label={ariaLabel ?? `${side} dock`}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full shrink-0 bg-surface",
|
||||||
|
// Children come first in the DOM; the flex direction places the handle
|
||||||
|
// on the inner edge: right of a left dock, left of a right dock.
|
||||||
|
side === "left"
|
||||||
|
? "flex-row border-r border-border"
|
||||||
|
: "flex-row-reverse border-l border-border",
|
||||||
|
)}
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{handle}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user