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>
764 lines
27 KiB
TypeScript
764 lines
27 KiB
TypeScript
/**
|
||
* `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>
|
||
);
|
||
}
|