/**
 * `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 } 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 { 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 { 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;
  title: string;
  projectName: string;
  subtitle: string;
  conversationId?: string;
}

interface PendingConversationOpen {
  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;
  }
}

export function ProjectsView() {
  const vm = useProjects();
  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 [viewerConversationId, setViewerConversationId] = useState<
    string | null
  >(null);
  const [pendingConversationOpen, setPendingConversationOpen] =
    useState<PendingConversationOpen | null>(null);
  const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);

  const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
  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);
    setViewerConversationId(null);
  }, [active?.id]);

  useEffect(() => {
    if (!active || pendingConversationOpen?.projectId !== active.id) return;
    setSettingsSection(null);
    setViewerConversationId(pendingConversationOpen.conversationId);
    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;
    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();
  }

  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);
    setViewerConversationId(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",
    "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>
                <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={() => {
          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}
            onSectionChange={setSettingsSection}
            onClose={() => setSettingsSection(null)}
          />
        ) : 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}
                onOpenPluginsSettings={() => {
                  setSettingsSection("plugins");
                  dismissFloating();
                }}
              />
            )}
          </>
        ) : (
          <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={() => 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>
  );
}
