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

@ -44,6 +44,7 @@ import { ProfilesSettings } from "@/features/first-run";
import { GitPanel, GitGraphView } from "@/features/git";
import {
Button,
DockRegion,
FloatingWindow,
Input,
MenuBar,
@ -56,33 +57,16 @@ import {
import { useGateways } from "@/app/di";
import { ProjectContextPanel } from "./ProjectContextPanel";
import { useProjects } from "./useProjects";
/** A panel reachable from the menu bar; each opens in a floating window. */
type PanelId =
| "projects"
| "context"
| "work"
| "tickets"
| "agents"
| "templates"
| "skills"
| "permissions"
| "memory"
| "git";
/** Human titles for each panel's floating window (also its aria-label). */
const PANEL_TITLE: Record<PanelId, string> = {
projects: "Projects",
context: "Project context",
work: "Work state",
tickets: "Tickets",
agents: "Agents",
templates: "Templates",
skills: "Skills",
permissions: "Permissions",
memory: "Memory",
git: "Git",
};
import {
PANEL_TITLE,
floatingPanels,
isDockedTo,
panelsDockedTo,
placementOf,
type PanelId,
type ViewPlacement,
type ViewPlacements,
} from "./viewPlacement";
/** Window width preset per panel (content-heavy panels get more room). */
const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
@ -123,8 +107,14 @@ export function ProjectsView() {
const { system } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Which panel's floating window is open (null = none). Replaces the sidebar.
const [openPanel, setOpenPanel] = useState<PanelId | null>(null);
// Placement of every open view (#22): each panel is "closed" (absent),
// "floating" (modal window), or docked left/right. One view = exactly one
// slot. Ephemeral local state in V1 — restore-at-restart is a deferred
// backend follow-up.
const [placements, setPlacements] = useState<ViewPlacements>({});
// Width (px) of each dock column, driven by the DockRegion resize handle.
const [leftDockWidth, setLeftDockWidth] = useState(340);
const [rightDockWidth, setRightDockWidth] = useState(340);
// Top-level view switch (#16): when true, the main area shows the AI Profiles
// settings instead of the project surface. The single menu bar stays visible
// so the user can toggle back from Settings → AI Profiles.
@ -196,11 +186,62 @@ export function ProjectsView() {
if (picked !== null) setRoot(picked);
}
// Opening a conversation viewer takes over the main area — close any panel
// window so it doesn't obscure the viewer (LS7).
// ── View placement (#22) ────────────────────────────────────────────────
// Move a view to a specific slot (floating or docked). Enforces the
// one-slot-per-view invariant implicitly (a panel has a single entry) and the
// floating surface stays single-window (the #16 modal contract): promoting a
// view to floating demotes any other floating view to closed. Docks hold as
// many views as fit — that's the multi-view surface.
function setPlacement(panel: PanelId, placement: ViewPlacement) {
setPlacements((prev) => {
const next: ViewPlacements = { ...prev };
if (placement === "floating") {
for (const other of Object.keys(next) as PanelId[]) {
if (other !== panel && next[other] === "floating") delete next[other];
}
}
next[panel] = placement;
return next;
});
}
// Close a view (remove its slot → "closed").
function closePanel(panel: PanelId) {
setPlacements((prev) => {
if (!(panel in prev)) return prev;
const next = { ...prev };
delete next[panel];
return next;
});
}
// Menu toggle: open floating when closed, otherwise close (whatever the slot).
function toggleFloating(panel: PanelId) {
if (placementOf(placements, panel) === "closed") {
setPlacement(panel, "floating");
} else {
closePanel(panel);
}
}
// Dismiss every floating view (docked views stay put). Used when a full-main
// surface takes over so a modal window doesn't obscure it.
function dismissFloating() {
setPlacements((prev) => {
let changed = false;
const next = { ...prev };
for (const panel of Object.keys(next) as PanelId[]) {
if (next[panel] === "floating") {
delete next[panel];
changed = true;
}
}
return changed ? next : prev;
});
}
// Opening a conversation viewer takes over the main area — dismiss floating
// windows so they don't obscure the viewer (LS7). Docked views stay beside it.
function openConversation(conversationId: string) {
setViewerConversationId(conversationId);
setOpenPanel(null);
dismissFloating();
}
// Switch the active project from the always-visible selector: activate its
@ -259,8 +300,8 @@ export function ProjectsView() {
{
id: "projects",
label: "Projects…",
active: openPanel === "projects",
onSelect: () => setOpenPanel("projects"),
active: placementOf(placements, "projects") !== "closed",
onSelect: () => toggleFloating("projects"),
},
],
},
@ -270,8 +311,8 @@ export function ProjectsView() {
items: viewItems.map((it) => ({
id: it.id,
label: it.label,
active: openPanel === it.id,
onSelect: () => setOpenPanel(it.id),
active: placementOf(placements, it.id) !== "closed",
onSelect: () => toggleFloating(it.id),
})),
},
{
@ -284,7 +325,7 @@ export function ProjectsView() {
active: showSettings,
onSelect: () => {
setShowSettings((v) => !v);
setOpenPanel(null);
dismissFloating();
},
},
],
@ -407,8 +448,83 @@ export function ProjectsView() {
}
// Projects manager renders inline in the welcome area only when it is not
// already shown in the floating window (avoids duplicate form inputs).
const showInlineProjects = !active && openPanel !== "projects";
// already shown as a panel (avoids duplicate form inputs).
const showInlineProjects =
!active && placementOf(placements, "projects") === "closed";
// Views by slot, in stable declaration order.
const leftPanels = panelsDockedTo(placements, "left");
const rightPanels = panelsDockedTo(placements, "right");
const floatingList = floatingPanels(placements);
// Placement toggle buttons for a view's header — the "flottant ↔ ancré
// (gauche/droite)" control from the contract. The button for the current slot
// is disabled so the active placement reads at a glance.
function DockControls({ panel }: { panel: PanelId }) {
const placement = placementOf(placements, panel);
const title = PANEL_TITLE[panel];
return (
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="ghost"
aria-label={`dock ${title} left`}
disabled={isDockedTo(placement, "left")}
onClick={() => setPlacement(panel, { dock: "left" })}
>
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`dock ${title} right`}
disabled={isDockedTo(placement, "right")}
onClick={() => setPlacement(panel, { dock: "right" })}
>
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`float ${title}`}
disabled={placement === "floating"}
onClick={() => setPlacement(panel, "floating")}
>
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`close ${title}`}
onClick={() => closePanel(panel)}
>
</Button>
</div>
);
}
// A single docked view inside a DockRegion column: header (title + placement
// controls) over the existing view component, reused as-is.
function renderDockedView(panel: PanelId): ReactNode {
return (
<section
key={panel}
aria-label={`${PANEL_TITLE[panel]} panel`}
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
>
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="truncate text-sm font-medium text-content">
{PANEL_TITLE[panel]}
</span>
<DockControls panel={panel} />
</header>
<div className="min-h-0 flex-1 overflow-auto p-3">
{renderPanel(panel)}
</div>
</section>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
@ -440,8 +556,22 @@ export function ProjectsView() {
{/* ── Menu bar (replaces the former left sidebar) ── */}
<MenuBar menus={menus} />
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
<main className="flex flex-1 flex-col overflow-hidden">
{/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow
resizable columns, not overlays — they sit beside the main surface. */}
<div className="flex min-h-0 flex-1 overflow-hidden">
{leftPanels.length > 0 && (
<DockRegion
side="left"
width={leftDockWidth}
onResize={setLeftDockWidth}
aria-label="left dock"
>
{leftPanels.map(renderDockedView)}
</DockRegion>
)}
{/* ── Main: AI Profiles / terminal grid / git graph / welcome ── */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{showSettings ? (
// Top-level view switch (#16): AI Profiles settings takes over the main
// area while the menu bar above stays visible to toggle back.
@ -491,19 +621,35 @@ export function ProjectsView() {
</div>
</div>
)}
</main>
</main>
{/* ── Panel floating window ── */}
{openPanel && (
{rightPanels.length > 0 && (
<DockRegion
side="right"
width={rightDockWidth}
onResize={setRightDockWidth}
aria-label="right dock"
>
{rightPanels.map(renderDockedView)}
</DockRegion>
)}
</div>
{/* ── Floating panel windows (modal overlays) ── */}
{floatingList.map((panel) => (
<FloatingWindow
key={panel}
open
title={PANEL_TITLE[openPanel]}
size={PANEL_SIZE[openPanel]}
onClose={() => setOpenPanel(null)}
title={PANEL_TITLE[panel]}
size={PANEL_SIZE[panel]}
onClose={() => closePanel(panel)}
>
{renderPanel(openPanel)}
<div className="mb-3 flex justify-end border-b border-border pb-3">
<DockControls panel={panel} />
</div>
{renderPanel(panel)}
</FloatingWindow>
)}
))}
{/* ── Background-task toasts (above floating windows) ── */}
{taskToasts.length > 0 && (
@ -523,7 +669,7 @@ export function ProjectsView() {
if (projectOpen) vm.activateTab(toast.projectId);
setShowSettings(false);
setViewerConversationId(null);
setOpenPanel("work");
setPlacement("work", "floating");
setTaskToasts((prev) =>
prev.filter((item) => item.id !== toast.id),
);