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:
2026-07-07 11:45:46 +02:00
parent 2f8467e2dd
commit 419e9b8498
6 changed files with 592 additions and 50 deletions

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

View File

@ -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),
); );

View 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",
);
}

View File

@ -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";

View 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);
});
});

View 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>
);
}