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:
@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user