Files
IdeaSDK/frontend/src/features/projects/ProjectsView.tsx
Blomios b4a01e0ae9 fix(view-windows): réconcilier l'état des fenêtres détachées au boot (#50)
Branche le consommateur frontend sur la commande backend
`list_open_view_windows` : le port window expose la liste des fenêtres
de panneaux détachées réellement ouvertes côté OS, l'adaptateur Tauri
(et son double mock) l'implémente, et `ProjectsView` réconcilie le
placement des vues au démarrage à partir de ce snapshot au lieu de
supposer un état. Couvre `viewPlacement` et la réconciliation par des
tests dédiés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:14:45 +02:00

764 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `ProjectsView` — the top-level project surface (L2 / L11).
*
* IDE layout (full remaining height), reworked in ticket #16:
*
* ┌───────────────────────────────────────────────────────┐
* │ PROJECT TABS [ alpha × ][ beta × ] [ + ] │
* ├───────────────────────────────────────────────────────┤
* │ MENU BAR Panneaux │ Settings │
* ├───────────────────────────────────────────────────────┤
* │ MAIN — LayoutGrid (fills the FULL width) │
* │ — or the projects manager / welcome when no project │
* └───────────────────────────────────────────────────────┘
*
* The former left sidebar is gone: every panel (context, work, tickets, agents,
* templates, skills, permissions, memory, git, projects) is reached from the
* single **Panneaux** menu (#26), whose per-panel submenu picks the placement
* directly — closed / floating / docked left/right / detached OS window — and
* shows it in a chrome-level {@link FloatingWindow} or {@link DockRegion}.
* Project creation/switching is the **Projects** panel, opened by the tab-bar
* "+" (or `Panneaux → Projects`) and, when no project is open, shown inline in
* the welcome area (so it is always reachable).
*
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
* `@/shared`; no inline styles beyond z-index tokens, no `invoke()`.
*
* **Test-contract** — the create-project form + known-projects list are in the
* DOM whenever no project is active (welcome area) or when the Projects window is
* open; the project tab bar (`role="tablist"`) is always present.
*/
import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ConversationViewer } from "@/features/conversations";
import { ProfilesSettings } from "@/features/first-run";
import { GitGraphView } from "@/features/git";
import {
Button,
DockRegion,
FloatingWindow,
IconButton,
Input,
MenuBar,
Panel,
cn,
zIndex,
type FloatingWindowSize,
type MenuBarItem,
type MenuBarMenu,
} from "@/shared";
import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects";
import { ProjectTabs } from "./ProjectTabs";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
PANEL_TITLE,
floatingPanels,
isDetached,
isDockedTo,
panelsDockedTo,
placementOf,
reconcileDetachedWindows,
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> = {
projects: "md",
context: "lg",
work: "lg",
tickets: "lg",
agents: "lg",
templates: "lg",
skills: "lg",
permissions: "md",
memory: "lg",
git: "lg",
};
interface BackgroundTaskToast {
id: string;
projectId: string;
agentId: string;
taskId: string;
state: string;
}
function isTerminalBackgroundTaskEvent(
event: DomainEvent,
): event is Extract<DomainEvent, { type: "backgroundTaskChanged" }> {
return (
event.type === "backgroundTaskChanged" &&
(event.state === "completed" ||
event.state === "failed" ||
event.state === "cancelled" ||
event.state === "delivered")
);
}
export function ProjectsView() {
const vm = useProjects();
const { system, window: windowGateway, focusedProject } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// 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.
const [showSettings, setShowSettings] = useState(false);
// The active layout (id + kind), reported by LayoutTabs — the single source of
// truth. `kind` decides whether the main area is the terminal grid or the git
// graph view.
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
// When set, the main area swaps the terminal grid for the read-only
// conversation viewer (LS7) — pure local UI state, same mechanic as the
// terminal↔gitGraph swap; **not** a backend layout kind.
const [viewerConversationId, setViewerConversationId] = useState<
string | null
>(null);
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
// Reset the active layout whenever the active project changes. `activeLayout`
// is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new
// project's layouts). Without this reset, the stale id of the *previous*
// project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and
// loading it against the new project's store fails with "not found: layout X".
useEffect(() => {
setActiveLayout(null);
setViewerConversationId(null);
}, [active?.id]);
// Publish the focused project (#47) so detached panel-only windows follow the
// main window: they render this project, or an "open a project" shell when
// none is active. Publish on EVERY change of `active` — including
// switching-away to null — so a restored panel window never shows a stale
// project. Optional-chained: unit tests may omit this gateway.
useEffect(() => {
void focusedProject?.setFocusedProject(
active ? { id: active.id, name: active.name, root: active.root } : null,
);
}, [focusedProject, active?.id, active?.name, active?.root]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system.onDomainEvent((event) => {
if (!isTerminalBackgroundTaskEvent(event)) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
};
setTaskToasts((prev) => [...prev.slice(-2), toast]);
window.setTimeout(() => {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
}, 7000);
}).then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system]);
const activeLayoutKind = activeLayout?.kind ?? "terminal";
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!canCreate) return;
await vm.createProject(name.trim(), root.trim());
setName("");
setRoot("");
}
async function handleBrowse() {
const picked = await system.pickFolder();
if (picked !== null) setRoot(picked);
}
// ── 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;
});
}
// 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;
});
}
// Detach a view into its own OS window (#23, panel-only in #47): ask the
// backend to open the window, then mark the slot "detached" so nothing renders
// for it in the main window. The window is panel-only and follows the focused
// project (published above), so no project id is passed. Requires an active
// project only so a detached window opens onto something rather than the empty
// shell. On failure the placement is left untouched (no ghost "detached" slot).
function detachPanel(panel: PanelId) {
if (!active) return;
void windowGateway
.openViewWindow(panel)
.then(() => setPlacement(panel, "detached"))
.catch(() => {
/* window failed to open; keep the current placement */
});
}
// When a detached window closes (OS close or programmatic), re-toggle its
// placement out of "detached" so it isn't stuck as an invisible ghost slot.
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void windowGateway
.onViewWindowClosed(({ panel }) => {
setPlacements((prev) =>
prev[panel as PanelId] === "detached"
? (() => {
const next = { ...prev };
delete next[panel as PanelId];
return next;
})()
: prev,
);
})
.then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [windowGateway]);
// On mount, reconcile `placements` with detached windows already open at the
// OS level (#50): a panel-only window restored by the OS before the main
// window mounted is unknown to `placements`, so the Panneaux menu would wrongly
// show it "Fermé". Enumerate them once and mark each as "detached" — but only
// best-effort and without clobbering more specific state: skip non-visible
// snapshots, skip `projects`/unknown panels, and never overwrite a placement
// the user (or a UI restore) already set to something other than "closed". A
// failed enumeration leaves `placements` untouched (no ghost slots). Independent
// of `active`: a panel-only window can be open with no active project.
useEffect(() => {
let cancelled = false;
void windowGateway
.listOpenViewWindows()
.then((snapshots) => {
if (cancelled) return;
setPlacements((prev) => reconcileDetachedWindows(prev, snapshots));
})
.catch(() => {
/* best-effort only: no reconciliation on failure */
});
return () => {
cancelled = true;
};
}, [windowGateway]);
// 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);
dismissFloating();
}
// ── Menus (#26) ─────────────────────────────────────────────────────────
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
// the placement actions directly (closed / floating / docked left/right /
// detached OS window). One place to pick the panel *and* its mode; the current
// placement is marked in the submenu (and the parent entry shows ● when the
// panel is open somewhere). Order per UX: content panels first, Projects last.
const panelOrder: PanelId[] = [
"context",
"work",
"tickets",
"agents",
"templates",
"skills",
"permissions",
"memory",
"git",
"projects",
];
// Placement actions for one panel, marking the active slot. `projects` is
// main-window-only chrome, so it never offers the detached-window action.
function placementSubmenu(panel: PanelId): MenuBarItem[] {
const placement = placementOf(placements, panel);
const items: MenuBarItem[] = [
{
id: "closed",
label: "Fermé",
active: placement === "closed",
onSelect: () => closePanel(panel),
},
{
id: "floating",
label: "Flottant",
active: placement === "floating",
onSelect: () => setPlacement(panel, "floating"),
},
{
id: "dock-left",
label: "Ancré à gauche",
active: isDockedTo(placement, "left"),
onSelect: () => setPlacement(panel, { dock: "left" }),
},
{
id: "dock-right",
label: "Ancré à droite",
active: isDockedTo(placement, "right"),
onSelect: () => setPlacement(panel, { dock: "right" }),
},
];
if (panel !== "projects") {
items.push({
id: "detached",
label: "Fenêtre détachée",
active: isDetached(placement),
disabled: !active,
onSelect: () => detachPanel(panel),
});
}
return items;
}
const menus: MenuBarMenu[] = [
{
id: "panels",
label: "Panneaux",
items: panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
active: placementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
})),
},
{
id: "settings",
label: "Settings",
items: [
{
id: "ai-profiles",
label: showSettings ? "Close AI Profiles" : "AI Profiles",
active: showSettings,
onSelect: () => {
setShowSettings((v) => !v);
dismissFloating();
},
},
],
},
];
// The create-project form + known-projects list. Rendered inline in the
// welcome area (no active project) or inside the Projects floating window.
const projectsManager: ReactNode = (
<div className="flex flex-col gap-4">
<form onSubmit={submit} className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
New project
</h3>
<Input
aria-label="project name"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex gap-2">
<Input
aria-label="project root"
placeholder="/absolute/project/root"
value={root}
onChange={(e) => setRoot(e.target.value)}
className="flex-1"
/>
<Button
type="button"
aria-label="browse project folder"
onClick={() => void handleBrowse()}
disabled={vm.busy}
>
Browse
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
disabled={!canCreate}
className="flex-1"
>
Create project
</Button>
<Button
type="button"
onClick={() => void vm.refresh()}
disabled={vm.busy}
>
Refresh
</Button>
</div>
</form>
<Panel title="Known projects">
{vm.projects.length === 0 ? (
<p className="text-sm text-muted">No projects yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
Open
</Button>
</li>
))}
</ul>
)}
</Panel>
</div>
);
// Body of a panel wherever it is placed (docked/floating). Delegates to the
// shared {@link ViewPanelBody} (also used by the detached ViewWindow) so a
// view is identical across placements. "projects" is main-window-only chrome.
function renderPanel(panel: PanelId): ReactNode {
if (panel === "projects") return projectsManager;
if (!active) {
return (
<p className="text-sm text-muted">Open a project to use this panel.</p>
);
}
return (
<ViewPanelBody
panel={panel as ViewPanelId}
projectId={active.id}
projectRoot={active.root}
onOpenConversation={openConversation}
/>
);
}
// Projects manager renders inline in the welcome area only when it is not
// 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 controls for a view's header — labelled icon-buttons aligned 1:1
// with the « Panneaux » menu wording (#42). Order: ⇤ gauche · ⇥ droite · □
// flottant · ↗ fenêtre détachée · × fermé. The *current* placement reads as
// ACTIVE (aria-pressed + ring), never disabled — a disabled control loses its
// tooltip/keyboard focus and conflates "impossible" with "already selected".
// Only genuinely-impossible actions are disabled (detach with no active
// project). All labelling flows through `title` + `aria-label`; no visible
// text. The detach control is hidden for the main-window-only `projects` view.
function DockControls({ panel }: { panel: PanelId }) {
const placement = placementOf(placements, panel);
const title = PANEL_TITLE[panel];
// Active (current-placement) styling — visually distinct, still clickable.
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
return (
<div className="flex shrink-0 items-center gap-1">
<IconButton
size="sm"
title="Ancré à gauche"
aria-label={`Ancré à gauche — ${title}`}
aria-pressed={isDockedTo(placement, "left")}
className={cn(isDockedTo(placement, "left") && activeClass)}
onClick={() => setPlacement(panel, { dock: "left" })}
>
</IconButton>
<IconButton
size="sm"
title="Ancré à droite"
aria-label={`Ancré à droite — ${title}`}
aria-pressed={isDockedTo(placement, "right")}
className={cn(isDockedTo(placement, "right") && activeClass)}
onClick={() => setPlacement(panel, { dock: "right" })}
>
</IconButton>
<IconButton
size="sm"
title="Flottant"
aria-label={`Flottant — ${title}`}
aria-pressed={placement === "floating"}
className={cn(placement === "floating" && activeClass)}
onClick={() => setPlacement(panel, "floating")}
>
</IconButton>
{panel !== "projects" && (
<IconButton
size="sm"
title="Fenêtre détachée"
aria-label={`Fenêtre détachée — ${title}`}
aria-pressed={isDetached(placement)}
disabled={!active}
className={cn(isDetached(placement) && activeClass)}
onClick={() => detachPanel(panel)}
>
</IconButton>
)}
<IconButton
size="sm"
title="Fermé"
aria-label={`Fermé — ${title}`}
onClick={() => closePanel(panel)}
>
×
</IconButton>
</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">
{/* ── Error alert ── */}
{vm.error && (
<p
role="alert"
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Project tab bar (#26): tabs = open projects, "+" opens Projects ── */}
<ProjectTabs
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {
setShowSettings(false);
setPlacement("projects", "floating");
}}
/>
{/* ── Menu bar (replaces the former left sidebar) ── */}
<MenuBar menus={menus} />
{/* ── 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.
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl">
<ProfilesSettings />
</div>
</div>
) : active && viewerConversationId ? (
<ConversationViewer
key={`${active.id}-${viewerConversationId}`}
projectId={active.id}
conversationId={viewerConversationId}
onClose={() => setViewerConversationId(null)}
/>
) : active ? (
<>
<LayoutTabs
projectId={active.id}
onActiveLayoutChange={setActiveLayout}
/>
{activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={active.id}
/>
) : (
<LayoutGrid
key={`${active.id}-${activeLayout?.id ?? "default"}`}
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={openConversation}
/>
)}
</>
) : (
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-xl">
{showInlineProjects ? (
projectsManager
) : (
<p className="text-sm text-muted">
Select or create a project to get started.
</p>
)}
</div>
</div>
)}
</main>
{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[panel]}
size={PANEL_SIZE[panel]}
onClose={() => closePanel(panel)}
>
<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 && (
<div
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
style={{ zIndex: zIndex.toast }}
>
{taskToasts.map((toast) => (
<button
key={toast.id}
type="button"
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
onClick={() => {
const projectOpen = vm.openTabs.some(
(tab) => tab.id === toast.projectId,
);
if (projectOpen) vm.activateTab(toast.projectId);
setShowSettings(false);
setViewerConversationId(null);
setPlacement("work", "floating");
setTaskToasts((prev) =>
prev.filter((item) => item.id !== toast.id),
);
}}
>
<span className="block text-sm font-medium text-content">
Background task {toast.state}
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
</span>
</button>
))}
</div>
)}
</div>
);
}