Files
IdeA/frontend/src/features/projects/ProjectsView.tsx
Blomios b9f8ed303e feat(backend,domain,infrastructure,application,app-tauri,web-server,frontend): suppression d'un projet du registre IdeA sans toucher son dossier disque — #308 (QA verte)
cause: l'utilisateur ne pouvait pas retirer un projet du registre IdeA (projects.json) ; la seule issue laissait l'entrée fantôme dans la liste des projets, sans capacité générique de retrait.

domain: port ProjectStore::remove_project(id) -> Option<Project> avec implémentation par défaut « not supported » pour ne pas casser les stores spécialisés.

infrastructure: FsProjectStore implémente remove_project — retire l'entrée du registre persisté et la renvoie, None si id inconnu, ordre relatif du registre préservé ; test store dédié.

application: use case RemoveProjectFromRegistry (remove.rs) — NotFound explicite si id absent, retourne le projet retiré + registre rafraîchi.

backend/app-tauri/web-server: DTO RemoveProjectRequestDto/RemoveProjectResponseDto (removed, projects, restore_state, was_open), adapter partagé remove_project_for_adapter réutilisé par la commande Tauri remove_project et la route HTTP — même contrat desktop/web ; remove_project_and_deactivate désactive le projet s'il était ouvert, purge l'entrée open-projects restore correspondante et renvoie was_open/restore_state ; test adapter vérifiant le contrat de bout en bout (entrée retirée, restore purgé, root intact).

frontend: DTO + ProjectGateway.removeProject (HTTP + mock), ProjectsView expose un menu d'actions projet (ProjectActionsMenu) avec entrée Retirer, RemoveProjectDialog — confirmation explicite précisant que le dossier disque n'est pas supprimé, invalidation/rechargement de la liste des projets après succès (useProjects), tests ProjectsView/projects/adapters/httpInvoker/services.

QA verte: cargo targeted tests/check OK (domain, application project_usecases, infrastructure project_store, backend, app-tauri, web-server), npm targeted/typecheck/build OK ; suite npm test complète rouge non bloquante hors #308 (échecs préexistants listProjectSettings/web). Vérifications Git: git diff --check OK, cargo fmt --all -- --check OK.

fichiers .ideai/tickets/308/, tickets/counter.json, tickets/index.json, attachments/ et scripts/ hors périmètre et préservés en worktree (sync store traité séparément) ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 18:27:15 +02:00

1210 lines
42 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 Settings / the projects manager / welcome │
* └───────────────────────────────────────────────────────┘
*
* **Settings** (`Settings → AI Profiles | Deployment`, #68) is a main surface
* with its own internal section nav — it has no `PanelId` and is outside the
* `viewPlacement` model, unlike every panel below.
*
* 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 {
Component,
useEffect,
useMemo,
useState,
type ErrorInfo,
type ReactNode,
} from "react";
import type {
Agent,
DomainEvent,
LayoutInfo,
OpenProjectRestoreFailure,
Project,
} from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ConversationViewer } from "@/features/conversations";
import {
SettingsView,
SETTINGS_SECTIONS,
SETTINGS_SECTION_LABEL,
type SettingsSection,
} from "@/features/settings";
import { GitGraphView } from "@/features/git";
import { AnnouncementsProvider } from "@/features/announcements";
import { usePluginMenus, usePluginRuntime } from "@/features/plugins";
import type { WhenContext } from "@/plugins/runtime";
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 { useProjectActivity } from "./useProjectActivity";
import { ProjectTabs } from "./ProjectTabs";
import { ProjectActionsMenu } from "./ProjectActionsMenu";
import { RemoveProjectDialog } from "./RemoveProjectDialog";
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",
mcp: "lg",
permissions: "md",
memory: "lg",
git: "lg",
};
interface BackgroundTaskToast {
id: string;
projectId: string;
agentId: string;
taskId: string;
state: string;
title: string;
projectName: string;
subtitle: string;
conversationId?: string;
}
interface PendingConversationOpen {
projectId: string;
conversationId: string;
}
interface OpenConversationViewer {
projectId: string;
conversationId: string;
}
interface PluginMenuRenderBoundaryProps {
resetKey: string;
fallbackMenus: MenuBarMenu[];
children: ReactNode;
}
class PluginMenuRenderBoundary extends Component<
PluginMenuRenderBoundaryProps,
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[plugins] menu render failed; using native menus only", {
error,
componentStack: errorInfo.componentStack,
});
}
componentDidUpdate(prevProps: PluginMenuRenderBoundaryProps): void {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
render(): ReactNode {
if (this.state.hasError) return <MenuBar menus={this.props.fallbackMenus} />;
return this.props.children;
}
}
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")
);
}
function shortTaskId(id: string): string {
return id.slice(0, 8);
}
function fallbackAgentLabel(id: string): string {
return shortTaskId(id);
}
function agentLabel(agents: Agent[], agentId: string): string {
return (
agents.find((candidate) => candidate.id === agentId)?.name ??
fallbackAgentLabel(agentId)
);
}
function projectToastName(
projectNameById: ReadonlyMap<string, string>,
projectId: string,
): string {
return projectNameById.get(projectId) ?? "Projet inconnu";
}
function rendezvousToastStateLabel(state: string): string {
switch (state) {
case "completed":
case "delivered":
return "terminé";
case "failed":
return "en échec";
case "cancelled":
return "annulé";
default:
return state;
}
}
function restoreFailureCause(failure: OpenProjectRestoreFailure): string {
switch (failure.code) {
case "notFound":
return "Le projet n’est plus enregistré.";
case "inaccessible":
return "Le chemin est introuvable ou inaccessible.";
case "store":
return "L’état du projet n’a pas pu être lu.";
case "invalid":
return "Les informations mémorisées pour ce projet sont invalides.";
}
}
export function ProjectsView() {
const vm = useProjects();
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
const {
system,
window: windowGateway,
focusedProject,
git,
agent,
} = 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, extended in #68): the open Settings section, or
// null when the main area shows the project surface. Settings is a main
// surface with its own internal nav — deliberately not a placeable panel. The
// menu bar stays visible above it.
const [settingsSection, setSettingsSection] = useState<SettingsSection | null>(
null,
);
// 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 [viewerConversation, setViewerConversation] =
useState<OpenConversationViewer | null>(null);
const [pendingConversationOpen, setPendingConversationOpen] =
useState<PendingConversationOpen | null>(null);
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false);
const [removalTarget, setRemovalTarget] = useState<Project | null>(null);
const [removalPending, setRemovalPending] = useState(false);
const [removalError, setRemovalError] = useState<string | null>(null);
const [removalNotice, setRemovalNotice] = useState<Project | null>(null);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
const activeViewerConversation =
viewerConversation?.projectId === active?.id ? viewerConversation : null;
const showRestoreNotification =
vm.restoreNotificationVisible && vm.restoreFailures.length > 0;
async function confirmProjectRemoval() {
if (!removalTarget || removalPending) return;
const removed = removalTarget;
setRemovalPending(true);
setRemovalError(null);
try {
await vm.removeProject(removed.id);
setRemovalTarget(null);
setRemovalNotice(removed);
} catch {
setRemovalError(
"Impossible de retirer ce projet d'IdeA. Le projet est toujours enregistré. Réessayez.",
);
} finally {
setRemovalPending(false);
}
}
useEffect(() => {
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
}, [vm.restoreFailures.length]);
const projectNameById = useMemo(() => {
const names = new Map<string, string>();
for (const project of vm.projects) names.set(project.id, project.name);
for (const tab of vm.openTabs) names.set(tab.id, tab.name);
return names;
}, [vm.projects, vm.openTabs]);
// `gitRepository` signal for the plugin `when` mini-language (#43, F3 —
// Architect-mandated, blocking for #43 closure): whether the *current*
// project is a git repository, checked for real via `GitGateway.branches`
// (a non-repo project rejects this call; a repo resolves it). Re-checked on
// every project switch. `agentSelected`/`terminalFocused`/
// `layoutCellFocused` remain accepted v1 debt — no global focus signal
// exists at the menu-bar level yet — and stay `false` (see the F3 report's
// open point).
const [gitRepository, setGitRepository] = useState(false);
useEffect(() => {
if (!active) {
setGitRepository(false);
return;
}
let cancelled = false;
git
.branches(active.id)
.then(() => {
if (!cancelled) setGitRepository(true);
})
.catch(() => {
if (!cancelled) setGitRepository(false);
});
return () => {
cancelled = true;
};
}, [git, active?.id]);
// Plugin contribution menus (#43, F3).
const { registry: pluginRegistry } = usePluginRuntime();
const pluginWhenCtx: WhenContext = {
projectOpen: active !== null,
gitRepository,
agentSelected: false,
terminalFocused: false,
layoutCellFocused: false,
};
const pluginMenus = usePluginMenus(pluginRegistry, pluginWhenCtx);
// 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);
}, [active?.id]);
useEffect(() => {
if (!active || pendingConversationOpen?.projectId !== active.id) return;
setSettingsSection(null);
setViewerConversation(pendingConversationOpen);
dismissFloating();
setPendingConversationOpen(null);
}, [active?.id, pendingConversationOpen]);
// 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;
void (async () => {
const hasRendezvousAgents = Boolean(
event.requesterAgentId && event.targetAgentId,
);
const labels = hasRendezvousAgents
? await agent
.listAgents(event.projectId)
.then((agents) => ({
requester: agentLabel(agents, event.requesterAgentId!),
target: agentLabel(agents, event.targetAgentId!),
}))
.catch(() => ({
requester: fallbackAgentLabel(event.requesterAgentId!),
target: fallbackAgentLabel(event.targetAgentId!),
}))
: null;
if (cancelled) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
title: labels
? `Appel ${labels.requester} -> ${labels.target} ${rendezvousToastStateLabel(event.state)}`
: `Background task ${event.state}`,
projectName: projectToastName(projectNameById, event.projectId),
subtitle: labels
? `Task ${shortTaskId(event.taskId)}`
: `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`,
...(event.conversationId
? { conversationId: event.conversationId }
: {}),
};
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?.();
};
}, [agent, projectNameById, 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;
setDetachErrorPanel(null);
void windowGateway
.openViewWindow(panel)
.then(() => {
setDetachErrorPanel(null);
setPlacement(panel, "detached");
})
.catch(() => {
// Keep the current placement (no ghost detached slot), but surface the
// failure so the action never appears to do nothing (#299).
setDetachErrorPanel(panel);
});
}
// 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 project content area — dismiss
// floating windows so they don't obscure the viewer (LS7). Docked views and
// the layout tabs stay available beside/above it.
function openConversation(conversationId: string) {
if (!active) return;
setViewerConversation({ projectId: active.id, conversationId });
dismissFloating();
}
async function handleTaskToastClick(toast: BackgroundTaskToast) {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
if (toast.conversationId) {
setPendingConversationOpen({
projectId: toast.projectId,
conversationId: toast.conversationId,
});
if (projectOpen) {
vm.activateTab(toast.projectId);
} else {
await vm.openProject(toast.projectId);
}
return;
}
if (projectOpen) vm.activateTab(toast.projectId);
setSettingsSection(null);
setPlacement("work", "floating");
}
// ── 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",
"mcp",
"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 nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
active: placementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
}));
const nativeSettingsItems: MenuBarItem[] = SETTINGS_SECTIONS.map((section) => ({
id: section,
label: SETTINGS_SECTION_LABEL[section],
active: settingsSection === section,
onSelect: () => {
setSettingsSection(section);
dismissFloating();
},
}));
const pluginPanelItems = pluginMenus.itemsFor("panels");
const pluginSettingsItems = pluginMenus.itemsFor("settings");
const nativeMenus: MenuBarMenu[] = [
{
id: "panels",
label: "Panneaux",
items: nativePanelItems,
},
{
id: "settings",
label: "Paramètres",
items: nativeSettingsItems,
},
];
const menus: MenuBarMenu[] = [
{
id: "panels",
label: "Panneaux",
// Native items first, plugin-contributed items after in their own
// (discreetly-labelled) group (#43, F3, carnet §7.2).
items: [...nativePanelItems, ...pluginPanelItems],
},
// Top-level plugin menus render between Panneaux and Paramètres (#43,
// carnet §0 UX decision + §7.1).
...pluginMenus.topLevelMenus,
{
id: "settings",
label: "Paramètres",
// One entry per section (#68). The entries name sections and mark the open
// one; closing lives in the view ("Fermer les paramètres"), so no label
// alternates. Plugin-contributed items are appended after (#43, F3).
items: [...nativeSettingsItems, ...pluginSettingsItems],
},
];
const pluginMenuResetKey = [
pluginMenus.topLevelMenus.map((menu) => menu.id).join(","),
pluginPanelItems.map((item) => item.id).join(","),
pluginSettingsItems.map((item) => item.id).join(","),
].join("|");
// 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>
<span className="flex shrink-0 items-center gap-1">
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
Open
</Button>
<ProjectActionsMenu
projectName={p.name}
disabled={vm.busy}
onOpen={() => void vm.openProject(p.id)}
onRemove={() => {
setRemovalError(null);
setRemovalTarget(p);
}}
/>
</span>
</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>
);
}
const body = (
<ViewPanelBody
panel={panel as ViewPanelId}
projectId={active.id}
projectRoot={active.root}
onOpenConversation={openConversation}
/>
);
return panel === "agents" ? (
<AnnouncementsProvider projectId={active.id}>{body}</AnnouncementsProvider>
) : (
body
);
}
// 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>
)}
{detachErrorPanel && (
<div
role="alert"
className="mx-4 mt-2 flex shrink-0 items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
<span>
Le panneau {PANEL_TITLE[detachErrorPanel]} ne peut pas être détaché
pour le moment.
</span>
<Button size="sm" onClick={() => detachPanel(detachErrorPanel)}>
Réessayer
</Button>
</div>
)}
{/* ── 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)}
activityByProjectId={projectActivity}
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {
setSettingsSection(null);
setPlacement("projects", "floating");
}}
/>
{/* ── Menu bar (replaces the former left sidebar) ── */}
<PluginMenuRenderBoundary
resetKey={pluginMenuResetKey}
fallbackMenus={nativeMenus}
>
<MenuBar menus={menus} />
</PluginMenuRenderBoundary>
{/* ── 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">
{settingsSection ? (
// Top-level view switch (#16/#68): Settings takes over the main area
// while the menu bar above stays visible.
<SettingsView
section={settingsSection}
projectId={active?.id}
onSectionChange={setSettingsSection}
onAssignPluginCapabilities={() => {
setSettingsSection(null);
if (placementOf(placements, "skills") === "closed") {
setPlacement("skills", "floating");
}
}}
onClose={() => setSettingsSection(null)}
/>
) : active ? (
<>
<LayoutTabs
projectId={active.id}
onActiveLayoutChange={setActiveLayout}
/>
{activeViewerConversation ? (
<ConversationViewer
key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
projectId={activeViewerConversation.projectId}
conversationId={activeViewerConversation.conversationId}
onClose={() => setViewerConversation(null)}
onOpenConversation={openConversation}
/>
) : activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={active.id}
/>
) : (
<AnnouncementsProvider projectId={active.id}>
<LayoutGrid
key={`${active.id}-${activeLayout?.id ?? "default"}`}
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={openConversation}
onOpenPluginsSettings={() => {
setSettingsSection("plugins");
dismissFloating();
}}
/>
</AnnouncementsProvider>
)}
</>
) : (
<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>
))}
<FloatingWindow
open={restoreDetailsOpen && vm.restoreFailures.length > 0}
title="Projets non rouverts"
size="md"
onClose={() => setRestoreDetailsOpen(false)}
>
<ul className="flex flex-col gap-3" aria-label="restore failures">
{vm.restoreFailures.map((failure) => (
<li
key={failure.projectId}
className="rounded-md border border-border bg-raised p-3"
>
<div className="text-sm font-medium text-content">{failure.name}</div>
<div className="mt-1 break-all text-xs text-muted">{failure.root}</div>
<p className="mt-2 text-sm text-muted">{restoreFailureCause(failure)}</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
variant="ghost"
loading={vm.busy}
onClick={() => void vm.removeFromRestore(failure.projectId)}
>
Retirer de la reprise
</Button>
</div>
</li>
))}
</ul>
</FloatingWindow>
{removalTarget && (
<RemoveProjectDialog
project={removalTarget}
isOpen={vm.openTabs.some((tab) => tab.id === removalTarget.id)}
pending={removalPending}
error={removalError}
onCancel={() => {
if (!removalPending) setRemovalTarget(null);
}}
onConfirm={() => void confirmProjectRemoval()}
/>
)}
{/* ── Non-blocking notifications (above floating windows) ── */}
{(showRestoreNotification || removalNotice || 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 }}
>
{showRestoreNotification && (
<div
role="status"
className="rounded-md border border-border bg-surface px-3 py-2 shadow-lg"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-content">
{vm.restoreFailures.length === 1
? "1 projet n’a pas pu être rouvert"
: `${vm.restoreFailures.length} projets n’ont pas pu être rouverts`}
</span>
<Button
size="sm"
variant="ghost"
aria-label="Fermer la notification"
onClick={vm.dismissRestoreNotification}
>
×
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" variant="ghost" onClick={() => setRestoreDetailsOpen(true)}>
Voir les détails
</Button>
<Button
size="sm"
variant="primary"
loading={vm.busy}
onClick={() => void vm.retryRestore()}
>
Réessayer
</Button>
</div>
</div>
)}
{removalNotice && (
<div
role="status"
className="rounded-md border border-border bg-surface px-3 py-2 shadow-lg"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm text-content">
« {removalNotice.name} » a été retiré d'IdeA. Les fichiers sont restés
sur le disque.
</span>
<Button
size="sm"
variant="ghost"
aria-label="Fermer la notification de retrait"
onClick={() => setRemovalNotice(null)}
>
×
</Button>
</div>
</div>
)}
{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={() => void handleTaskToastClick(toast)}
>
<span className="block text-sm font-medium text-content">
{toast.title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{toast.projectName} · {toast.subtitle}
</span>
</button>
))}
</div>
)}
</div>
);
}