Merge pull request #1994 from Molecule-AI/fix/canvas-multilevel-layout-ux

fix(canvas): subtree-aware layout + org-import reliability + UX polish
This commit is contained in:
Hongming Wang 2026-04-24 06:57:10 +00:00 committed by GitHub
commit 2821b979f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 630 additions and 92 deletions

View File

@ -6,9 +6,27 @@ import { api } from "@/lib/api";
import { showToast } from "./Toaster";
import { ConsoleModal } from "./ConsoleModal";
/** Default provisioning timeout in milliseconds (2 minutes). */
/** Base provisioning timeout in milliseconds (2 minutes). Used as the
* floor; the effective threshold scales with the number of workspaces
* concurrently provisioning (see effectiveTimeoutMs below). */
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;
/** The server provisions up to `PROVISION_CONCURRENCY` containers at
* once and paces the rest in a queue (`workspaceCreatePacingMs` =
* 2s). Mirrors the Go constants if those change, bump these. */
const PROVISION_CONCURRENCY = 3;
const PER_QUEUE_SLOT_EXTRA_MS = 45_000; // ~45s head-room per queued workspace
/** Scale the base timeout by how many workspaces are provisioning at
* once. A 30-workspace org import has tail items that legitimately
* wait minutes before Docker even starts on them flagging each as
* "stuck" after 2m creates a wall of 27 yellow banners that buries
* the canvas. */
function effectiveTimeoutMs(base: number, concurrentCount: number): number {
const overflow = Math.max(0, concurrentCount - PROVISION_CONCURRENCY);
return base + overflow * PER_QUEUE_SLOT_EXTRA_MS;
}
interface TimeoutEntry {
workspaceId: string;
workspaceName: string;
@ -33,6 +51,10 @@ export function ProvisioningTimeout({
const [retrying, setRetrying] = useState<Set<string>>(new Set());
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const trackingRef = useRef<Map<string, number>>(new Map());
// Workspaces the user explicitly dismissed — don't re-show their
// banner even if they stay in provisioning. Cleared when the
// workspace leaves provisioning (status changes).
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
// Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render
// (filter+map creates new array reference on every store update)
@ -71,17 +93,34 @@ export function ProvisioningTimeout({
}
}
// Also remove from timedOut list if no longer provisioning
// Also remove from timedOut list if no longer provisioning, and
// clear `dismissed` entries for workspaces that finished so a
// re-provision (e.g. retry) can surface a fresh banner.
setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId)));
setDismissed((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of prev) {
if (!activeIds.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : prev;
});
// Interval to check for timeouts
const interval = setInterval(() => {
const now = Date.now();
const newTimedOut: TimeoutEntry[] = [];
const effective = effectiveTimeoutMs(
timeoutMs,
parsedProvisioningNodes.length,
);
for (const node of parsedProvisioningNodes) {
const startedAt = tracking.get(node.id);
if (startedAt && now - startedAt >= timeoutMs) {
if (startedAt && now - startedAt >= effective) {
newTimedOut.push({
workspaceId: node.id,
workspaceName: node.name,
@ -104,6 +143,11 @@ export function ProvisioningTimeout({
return () => clearInterval(interval);
}, [parsedProvisioningNodes, timeoutMs]);
const handleDismiss = useCallback((workspaceId: string) => {
setDismissed((prev) => new Set(prev).add(workspaceId));
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
}, []);
const RETRY_COOLDOWN_MS = 5_000;
const [retryCooldown, setRetryCooldown] = useState<Set<string>>(new Set());
@ -180,11 +224,16 @@ export function ProvisioningTimeout({
setConsoleFor(workspaceId);
}, []);
if (timedOut.length === 0) return null;
const visibleTimedOut = useMemo(
() => timedOut.filter((e) => !dismissed.has(e.workspaceId)),
[timedOut, dismissed],
);
if (visibleTimedOut.length === 0) return null;
return (
<div role="alert" aria-live="assertive" className="fixed top-14 left-1/2 -translate-x-1/2 z-40 flex flex-col gap-2 max-w-[480px] w-full px-4">
{timedOut.map((entry) => {
{visibleTimedOut.map((entry) => {
const elapsed = Math.round((Date.now() - entry.startedAt) / 1000);
const isRetrying = retrying.has(entry.workspaceId);
const isCancelling = cancelling.has(entry.workspaceId);
@ -210,8 +259,20 @@ export function ProvisioningTimeout({
</div>
<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-amber-200 mb-0.5">
Provisioning Timeout
<div className="flex items-center justify-between mb-0.5 gap-2">
<div className="text-[12px] font-semibold text-amber-200">
Provisioning Timeout
</div>
<button
onClick={() => handleDismiss(entry.workspaceId)}
aria-label="Dismiss provisioning timeout warning"
title="Dismiss — keep this workspace running without the warning"
className="shrink-0 text-amber-400/60 hover:text-amber-200 transition-colors -mr-1"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="text-[11px] text-amber-300/80 leading-relaxed">
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}

View File

@ -3,10 +3,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
import { showToast } from "./Toaster";
import { TIER_CONFIG } from "@/lib/design-tokens";
interface Template {
@ -40,10 +42,41 @@ export async function fetchOrgTemplates(): Promise<OrgTemplate[]> {
}
}
/** Import an org template by directory name. Throws on platform error so the
* caller can surface the message in its error state. */
export async function importOrgTemplate(dir: string): Promise<void> {
await api.post("/org/import", { dir });
/** Server response from POST /org/import. The handler returns 207
* (StatusMultiStatus) with a populated `error` field when only some of
* the workspaces in the tree could be created the HTTP status alone
* isn't enough to detect a partial failure. */
interface OrgImportResponse {
org: string;
workspaces: Array<{ id: string; name: string }>;
count: number;
error?: string;
}
/** Import an org template by directory name. Throws on platform error
* so the caller can surface the message in its error state. Also throws
* on 2xx-with-error-body (StatusMultiStatus) without this check a
* partial failure (e.g. first workspace INSERT fails, 0 created)
* appears as a green success toast and the user sees no canvas update.
*
* Uses a long timeout because createWorkspaceTree paces sibling DB
* inserts by `workspaceCreatePacingMs` (2s) to avoid overwhelming
* Docker a 15-workspace tree sleeps ~28s in the handler alone,
* which blows past the default 15s and makes the client report a
* spurious "signal timed out" error even though the server finished
* successfully. 2min covers trees up to ~60 workspaces. */
const ORG_IMPORT_TIMEOUT_MS = 120_000;
export async function importOrgTemplate(dir: string): Promise<OrgImportResponse> {
const resp = await api.post<OrgImportResponse>(
"/org/import",
{ dir },
{ timeoutMs: ORG_IMPORT_TIMEOUT_MS },
);
if (resp && resp.error) {
throw new Error(`${resp.error} (created ${resp.count ?? 0} workspaces)`);
}
return resp;
}
/**
@ -81,8 +114,22 @@ export function OrgTemplatesSection() {
setError(null);
try {
await importOrgTemplate(org.dir);
// Refresh canvas inline — the WebSocket may be offline, in which case
// WORKSPACE_PROVISIONING broadcasts never arrive and the user sees
// no change from clicking "Import org". A direct fetch guarantees
// the new workspaces land on canvas regardless of WS state.
try {
const workspaces = await api.get<WorkspaceData[]>("/workspaces");
useCanvasStore.getState().hydrate(workspaces);
} catch {
// Rehydrate failure is non-fatal; WS (if alive) or the next
// health-check cycle will eventually pick the new workspaces up.
}
showToast(`Imported "${org.name || org.dir}" (${org.workspaces} workspaces)`, "success");
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
const msg = e instanceof Error ? e.message : "Import failed";
setError(msg);
showToast(`Import failed: ${msg}`, "error");
} finally {
setImporting(null);
}

View File

@ -137,7 +137,11 @@ export function Toolbar() {
<span className="text-[11px] font-semibold text-zinc-300 tracking-wide">Molecule AI</span>
</div>
{/* Status counts */}
{/* Status pills + workspace total in one segment previously two
separate border-delimited cells; merged to drop a redundant
divider and keep the count compact. `whitespace-nowrap` prevents
"+ N sub" from wrapping onto a second line when the toolbar
gets tight. */}
<div className="flex items-center gap-2.5">
<StatusPill color={statusDotClass("online")} count={counts.online} label="online" />
{counts.offline > 0 && (
@ -149,11 +153,8 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
</div>
{/* Total */}
<div className="pl-3 border-l border-zinc-800/60">
<span className="text-[10px] text-zinc-500">
<span className="text-zinc-700" aria-hidden="true">·</span>
<span className="text-[10px] text-zinc-500 whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-zinc-600"> + {counts.children} sub</span>}
</span>
@ -200,13 +201,18 @@ export function Toolbar() {
</button>
)}
{/* Secondary tools below are icon-only (Figma/Linear pattern) text
label is exposed via title + aria-label for hover/screen-reader
users. The primary Stop All / Restart Pending buttons above keep
their text because they are urgent + conditional. */}
{/* A2A topology overlay toggle */}
<button
onClick={() => setShowA2AEdges(!showA2AEdges)}
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center gap-1.5 px-2.5 py-1 border rounded-lg transition-colors ${
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
@ -214,8 +220,8 @@ export function Toolbar() {
>
{/* Mesh / network icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
@ -231,7 +237,6 @@ export function Toolbar() {
strokeLinecap="round"
/>
</svg>
<span className="text-[10px] font-medium">A2A</span>
</button>
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
@ -244,13 +249,13 @@ export function Toolbar() {
}
}}
aria-label="Open audit trail for selected workspace"
title="View audit ledger for the selected workspace"
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
title="Audit — view ledger for the selected workspace"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
{/* Scroll / ledger icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
@ -259,35 +264,34 @@ export function Toolbar() {
<rect x="3" y="2" width="10" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M6 5.5h4M6 8h4M6 10.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
<span className="text-[10px] font-medium">Audit</span>
</button>
{/* Search shortcut */}
<button
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
aria-label="Search workspaces"
title="Search (⌘K)"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="text-[10px] text-zinc-500">Search</span>
<kbd className="text-[8px] text-zinc-600 bg-zinc-900/60 px-1 py-0.5 rounded border border-zinc-700/30">K</kbd>
</button>
{/* Quick help */}
<div ref={helpRef} className="relative">
<button
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
aria-expanded={helpOpen}
aria-label="Open quick help"
title="Help — shortcuts & quick start"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12v.5M6.5 6.3A1.9 1.9 0 1 1 9 8.1c-.7.4-1 .8-1 1.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[10px] text-zinc-500">Help</span>
</button>
{helpOpen && (

View File

@ -202,9 +202,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
);
})()}
{/* Role */}
{/* Role clamp to 2 lines. Without this, a verbose role
* description (common on org-template imports) lets the card
* grow arbitrarily tall, which wrecks the grid-slot layout
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
{data.role && (
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight">{data.role}</div>
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight line-clamp-2">{data.role}</div>
)}
{/* Skills */}

View File

@ -73,6 +73,26 @@ describe("importOrgTemplate", () => {
mockFetch.mockRejectedValueOnce(new Error("offline"));
await expect(importOrgTemplate("x")).rejects.toThrow("offline");
});
it("treats 2xx with `error` field as a failure (StatusMultiStatus partial)", async () => {
// Server returns 207 — `api.post` treats the 2xx as success and
// returns the body. Without the post-check, a partial failure
// (0 workspaces created) would surface as a green "Imported"
// toast and the user would see no canvas change.
mockFetch.mockResolvedValueOnce({
ok: true,
status: 207,
json: async () => ({
org: "Data Team",
workspaces: [],
count: 0,
error: 'pq: column "collapsed" of relation "workspaces" does not exist',
}),
});
await expect(importOrgTemplate("data-team")).rejects.toThrow(
/collapsed.*relation.*workspaces.*created 0 workspaces/,
);
});
});
describe("module exports", () => {

View File

@ -25,14 +25,59 @@ export function useCanvasViewport() {
const saveViewport = useCanvasStore((s) => s.saveViewport);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const autoFitTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Tracks whether any workspace was provisioning on the previous
// render so we can detect the boundary when the last one finishes
// and auto-fit the viewport around the whole tree.
const hadProvisioningRef = useRef(false);
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current);
clearTimeout(panTimerRef.current);
clearTimeout(autoFitTimerRef.current);
};
}, []);
// Auto-fit the viewport once all workspaces finish provisioning. Org
// imports land dozens of new nodes off-screen; without a follow-up
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
const provisioningCount = useCanvasStore(
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
);
const nodeCount = useCanvasStore((s) => s.nodes.length);
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
const wasProvisioning = hadProvisioningRef.current;
hadProvisioningRef.current = hasProvisioning;
if (wasProvisioning && !hasProvisioning && nodeCount > 0) {
clearTimeout(autoFitTimerRef.current);
// 1200ms settle delay: lets React Flow's DOM measurement pass
// resize newly-online parents before we compute bounds.
// Measuring too early gives us the pre-render skeleton bbox and
// fitView zooms to that smaller-than-real rectangle.
autoFitTimerRef.current = setTimeout(() => {
fitView({
duration: 1200,
padding: 0.25,
// Cap zoom-in: a small tree (2-3 nodes) would otherwise end
// up at the 2x maxZoom, visually implying "something is
// wrong". 0.8 reads like "here's your whole org" even when
// the tree is small.
maxZoom: 0.8,
// Cap zoom-out: fitView would fall back to the component's
// minZoom=0.1 on a sparse/outlier layout, leaving the user
// staring at a postage-stamp canvas. 0.25 is the floor.
minZoom: 0.25,
});
}, 1200);
}
}, [provisioningCount, nodeCount, fitView]);
// Pan to a newly deployed / targeted workspace. 100ms delay so React
// Flow has time to measure a just-rendered node.
useEffect(() => {

View File

@ -11,14 +11,22 @@ export const PLATFORM_URL =
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance.
// the error and render a retry affordance. Callers that know the
// endpoint is intentionally slow (org import walks a tree of
// workspaces with server-side pacing) can pass `timeoutMs` to
// override.
const DEFAULT_TIMEOUT_MS = 15_000;
export interface RequestOptions {
timeoutMs?: number;
}
async function request<T>(
method: string,
path: string,
body?: unknown,
retryCount = 0,
options?: RequestOptions,
): Promise<T> {
// SaaS cross-origin shape:
// - X-Molecule-Org-Slug: derived from window.location.hostname by
@ -37,7 +45,7 @@ async function request<T>(
headers,
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
// Transient rate-limit recovery. A single IP bucket can momentarily
// spike on page load (several panels hydrate simultaneously). Instead
@ -49,7 +57,7 @@ async function request<T>(
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
const delayMs = Number.isFinite(retryAfter) ? Math.min(retryAfter, 20) * 1000 : 2000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return request<T>(method, path, body, retryCount + 1);
return request<T>(method, path, body, retryCount + 1, options);
}
if (res.status === 401) {
// Session expired or credentials lost. On SaaS (tenant subdomain)
@ -75,9 +83,9 @@ async function request<T>(
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
patch: <T>(path: string, body?: unknown) => request<T>("PATCH", path, body),
put: <T>(path: string, body?: unknown) => request<T>("PUT", path, body),
del: <T>(path: string) => request<T>("DELETE", path),
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, 0, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, 0, options),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PATCH", path, body, 0, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, 0, options),
del: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, 0, options),
};

View File

@ -914,6 +914,39 @@ describe("setCollapsed", () => {
}));
expect(afterHydrate).toEqual(afterCollapse);
});
it("sizes the expanded parent to fit nested-parent children, not leaf-count", () => {
// Regression: when a collapsed parent contains a child that is
// itself a parent (CTO → Dev Lead → 6 engineers), expanding must
// use each direct child's actual rendered size — not the
// leaf-count formula. Otherwise the container is too small and
// Dev Lead (wide enough for 6 engineers in a grid) overflows.
useCanvasStore.getState().hydrate([
makeWS({ id: "cto", name: "CTO", collapsed: true }),
makeWS({ id: "devLead", name: "Dev Lead", parent_id: "cto" }),
makeWS({ id: "fe", name: "Frontend", parent_id: "devLead" }),
makeWS({ id: "be", name: "Backend", parent_id: "devLead" }),
makeWS({ id: "mo", name: "Mobile", parent_id: "devLead" }),
makeWS({ id: "do", name: "DevOps", parent_id: "devLead" }),
makeWS({ id: "se", name: "Security", parent_id: "devLead" }),
makeWS({ id: "qa", name: "QA", parent_id: "devLead" }),
]);
const devLeadNode = useCanvasStore
.getState()
.nodes.find((n) => n.id === "devLead")!;
const devLeadW = devLeadNode.width as number;
useCanvasStore.getState().setCollapsed("cto", false);
const ctoAfter = useCanvasStore
.getState()
.nodes.find((n) => n.id === "cto")!;
// CTO's new width must be wide enough to host its Dev Lead child
// plus the parent's own padding. Leaf-count formula would yield
// ~272 (one 240px leaf slot); subtree-aware should be ≥ Dev Lead
// plus side padding.
expect(ctoAfter.width).toBeGreaterThanOrEqual(devLeadW);
});
});
// ---------- bumpZOrder ----------

View File

@ -38,12 +38,26 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
return out;
}
export const CHILD_DEFAULT_WIDTH = 260;
export const CHILD_DEFAULT_HEIGHT = 140;
export const PARENT_HEADER_PADDING = 60; // room for the parent's own header
export const PARENT_SIDE_PADDING = 20;
export const PARENT_BOTTOM_PADDING = 20;
export const CHILD_GUTTER = 20;
// Grid-slot defaults for children laid under a parent. The card
// component (WorkspaceNode.tsx) sets `max-w-[240px]` on leaves, so a
// slot stride of CHILD_DEFAULT_WIDTH + CHILD_GUTTER guarantees cards
// never bleed into their neighbour's slot. Keep these in sync with
// the Go mirror in workspace-server/internal/handlers/org.go —
// changing one without the other leads to import-time / runtime drift.
export const CHILD_DEFAULT_WIDTH = 240;
export const CHILD_DEFAULT_HEIGHT = 130;
// Parent header space — reserves room above the child grid so the
// parent's own name + runtime pill + clamped role + currentTask
// banner aren't covered by the first row of child cards. The
// currentTask banner appears on freshly-provisioning agents (initial
// prompt gets queued as their current task) and adds ~30px below the
// role; without this headroom, the first child overlaps the amber
// banner and makes the parent card look broken on import. Keep in
// sync with the Go mirror in org.go.
export const PARENT_HEADER_PADDING = 130;
export const PARENT_SIDE_PADDING = 16;
export const PARENT_BOTTOM_PADDING = 16;
export const CHILD_GUTTER = 14;
/**
@ -52,6 +66,9 @@ export const CHILD_GUTTER = 20;
* and to rescue children whose stored position puts them outside the
* parent's bounding box. 2-column grid is wide enough to read but
* narrow enough to keep the parent card from becoming a widescreen.
*
* Leaf-sized slots only for variable-size siblings (mix of leaves
* and nested parents), use `childSlotInGrid` below instead.
*/
export function defaultChildSlot(index: number): { x: number; y: number } {
const col = index % 2;
@ -62,16 +79,66 @@ export function defaultChildSlot(index: number): { x: number; y: number } {
return { x, y };
}
export interface NodeSize {
width: number;
height: number;
}
/** Grid column count for laying children inside a parent. Matches the
* Go server mirror (childGridColumnCount). */
const GRID_COLS = 2;
/** Utility: per-row max height in a size[] laid out column-major. */
function rowHeightsOf(sizes: NodeSize[], cols: number): number[] {
const rows = Math.ceil(sizes.length / cols);
const out = new Array(rows).fill(0);
sizes.forEach((s, i) => {
const row = Math.floor(i / cols);
out[row] = Math.max(out[row], s.height);
});
return out;
}
/** Uniform column width = max of all sibling widths. Keeps the grid
* rectangular (alternative: variable col widths visually unstable
* when one sibling is much wider than the rest). */
function colWidthOf(sizes: NodeSize[]): number {
return sizes.reduce((m, s) => Math.max(m, s.width), 0);
}
/**
* Minimum parent size that still fits `childCount` children laid out via
* defaultChildSlot. Never shrinks below the leaf-card min.
* Grid slot for the n-th sibling when siblings have variable sizes
* (e.g., a mix of leaves and nested parents). Uniform column width +
* per-row max height, so bigger nested parents push their row down
* without displacing columns.
*/
export function childSlotInGrid(
index: number,
siblingSizes: NodeSize[],
): { x: number; y: number } {
if (siblingSizes.length === 0) return { x: PARENT_SIDE_PADDING, y: PARENT_HEADER_PADDING };
const cols = Math.min(GRID_COLS, siblingSizes.length);
const col = index % cols;
const row = Math.floor(index / cols);
const colW = colWidthOf(siblingSizes);
const rowHs = rowHeightsOf(siblingSizes, cols);
const x = PARENT_SIDE_PADDING + col * (colW + CHILD_GUTTER);
let y = PARENT_HEADER_PADDING;
for (let r = 0; r < row; r++) y += rowHs[r] + CHILD_GUTTER;
return { x, y };
}
/**
* Minimum parent size that still fits `childCount` uniformly-sized
* children. Leaf-slot variant kept for back-compat with callers that
* don't have per-child sizes (bumpZOrder, arrangeChildren).
*/
export function parentMinSize(childCount: number): { width: number; height: number } {
if (childCount <= 0) {
return { width: 210, height: 120 };
}
const cols = Math.min(2, childCount);
const rows = Math.ceil(childCount / 2);
const cols = Math.min(GRID_COLS, childCount);
const rows = Math.ceil(childCount / cols);
const width =
PARENT_SIDE_PADDING * 2 +
cols * CHILD_DEFAULT_WIDTH +
@ -84,6 +151,30 @@ export function parentMinSize(childCount: number): { width: number; height: numb
return { width, height };
}
/**
* Minimum parent size that fits a set of (possibly non-uniform)
* children. Uniform column width, per-row max height matches the
* geometry produced by `childSlotInGrid`. Used when a parent has
* grandchildren and a leaf-slot-sized grid can't hold the real,
* bigger nested cards.
*/
export function parentMinSizeFromChildren(children: NodeSize[]): NodeSize {
if (children.length === 0) return { width: 210, height: 120 };
const cols = Math.min(GRID_COLS, children.length);
const rows = Math.ceil(children.length / cols);
const colW = colWidthOf(children);
const rowHs = rowHeightsOf(children, cols);
const totalRowH = rowHs.reduce((a, b) => a + b, 0);
return {
width: PARENT_SIDE_PADDING * 2 + colW * cols + CHILD_GUTTER * (cols - 1),
height:
PARENT_HEADER_PADDING +
totalRowH +
CHILD_GUTTER * (rows - 1) +
PARENT_BOTTOM_PADDING,
};
}
/**
* Computes auto-layout positions for workspaces that have no persisted position
* (x === 0 AND y === 0). Workspaces with an existing non-zero position are used
@ -236,13 +327,44 @@ export function buildNodesAndEdges(
}
}
// Index direct children per parent for post-order subtree sizing.
// We walk `sorted` in REVERSE (post-order — children first) so
// subtreeSize[parent] sees its grandchildren-inclusive sizes via the
// already-computed subtreeSize[child].
const childrenByParent = new Map<string, WorkspaceData[]>();
for (const ws of workspaces) {
if (ws.parent_id && byId.has(ws.parent_id)) {
const arr = childrenByParent.get(ws.parent_id) ?? [];
arr.push(ws);
childrenByParent.set(ws.parent_id, arr);
}
}
const subtreeSize = new Map<string, NodeSize>();
for (let i = sorted.length - 1; i >= 0; i--) {
const ws = sorted[i];
const kids = childrenByParent.get(ws.id) ?? [];
if (kids.length === 0 || ws.collapsed) {
subtreeSize.set(ws.id, { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT });
} else {
const kidSizes = kids.map((k) =>
subtreeSize.get(k.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
subtreeSize.set(ws.id, parentMinSizeFromChildren(kidSizes));
}
}
// Track each parent's initial size so we can reset children that land
// outside those bounds. Parents without children fall back to the leaf
// default; parents with children get the grid-derived minimum.
// default; parents with children get the grid-derived minimum — which
// now accounts for grandchildren via subtreeSize, so a nested parent
// no longer overflows its slot.
const parentSize = new Map<string, { width: number; height: number }>();
for (const ws of workspaces) {
const n = childCounts.get(ws.id) ?? 0;
parentSize.set(ws.id, n > 0 ? parentMinSize(n) : { width: 260, height: 140 });
// Reuse subtreeSize — it already accounts for nested grandchildren.
parentSize.set(
ws.id,
subtreeSize.get(ws.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
}
// Running index of children already placed per parent — used to hand
@ -318,14 +440,21 @@ export function buildNodesAndEdges(
// legacy huge positive (position.x = 50000):
// child.left = 50000 >= parent.right → no overlap → rescued
const psize = parentSize.get(ws.parent_id!)!;
const myW = subtreeSize.get(ws.id)?.width ?? CHILD_DEFAULT_WIDTH;
const myH = subtreeSize.get(ws.id)?.height ?? CHILD_DEFAULT_HEIGHT;
const overlapsX =
position.x + CHILD_DEFAULT_WIDTH > 0 && position.x < psize.width;
position.x + myW > 0 && position.x < psize.width;
const overlapsY =
position.y + CHILD_DEFAULT_HEIGHT > 0 && position.y < psize.height;
position.y + myH > 0 && position.y < psize.height;
if (!overlapsX || !overlapsY) {
const idx = nextChildIndex.get(ws.parent_id!) ?? 0;
nextChildIndex.set(ws.parent_id!, idx + 1);
position = defaultChildSlot(idx);
// Use sibling-size-aware grid so a nested parent doesn't collide
// with a leaf sibling in the next row.
const siblings = (childrenByParent.get(ws.parent_id!) ?? []).map(
(c) => subtreeSize.get(c.id) ?? { width: CHILD_DEFAULT_WIDTH, height: CHILD_DEFAULT_HEIGHT },
);
position = childSlotInGrid(idx, siblings);
}
}
const node: Node<WorkspaceNodeData> = {
@ -365,15 +494,26 @@ export function buildNodesAndEdges(
if (hiddenById.get(ws.id)) {
node.hidden = true;
}
// Give parents a measured-ish starting size so NodeResizer has a
// baseline and child positions have somewhere to live. Without this,
// parents start at React Flow's default min size (well under a
// single child) and children render visually outside their parent
// until the next resize measurement settles.
if ((childCounts.get(ws.id) ?? 0) > 0) {
// Seed every node with an explicit starting size so the initial
// grid layout is stable before React Flow has measured the DOM.
// - Parents (has children, not collapsed): sized to fit the
// child grid via parentMinSize so children don't render
// outside the bounds on first paint.
// - Collapsed parents: leaf-sized (header-only card).
// - Leaves: leaf-sized — they land in their grid slot cleanly.
//
// NodeResizer still drives user-initiated growth at runtime; these
// are only the initial values, and React Flow updates them in place
// when the user drags a resize handle. A future hydrate() will
// reset to the default until we persist width/height server-side.
const kids = childCounts.get(ws.id) ?? 0;
if (kids > 0 && !ws.collapsed) {
const size = parentSize.get(ws.id)!;
node.width = size.width;
node.height = size.height;
} else {
node.width = CHILD_DEFAULT_WIDTH;
node.height = CHILD_DEFAULT_HEIGHT;
}
return node;
});

View File

@ -13,6 +13,7 @@ import {
buildNodesAndEdges,
computeAutoLayout,
defaultChildSlot,
parentMinSizeFromChildren,
sortParentsBeforeChildren,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
@ -836,15 +837,42 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
stack.push({ id: childId, hidden: hidden || isCollapsed });
}
}
// Expanded size must fit the target's ACTUAL children, including
// any nested-parent children that are themselves oversized. Using a
// leaf-count formula (parentMinSize) would undersize the parent
// whenever a child was itself a team — e.g. CTO expanding to show
// Dev Lead (which carries 6 engineers) would render Dev Lead
// clipped. Read each direct child's current width/height from the
// node itself; those already reflect the subtree sizing computed
// in buildNodesAndEdges.
const directChildIds = childrenByParent.get(parentId) ?? [];
const childSizes = directChildIds.map((cid) => {
const cn = nodes.find((n) => n.id === cid);
return {
width: (cn?.width as number | undefined) ?? CHILD_DEFAULT_WIDTH,
height: (cn?.height as number | undefined) ?? CHILD_DEFAULT_HEIGHT,
};
});
const expandedSize = parentMinSizeFromChildren(childSizes);
set({
nodes: nodes.map((n) => {
const isTarget = n.id === parentId;
const nextHidden = hiddenById.get(n.id) ?? false;
if (!isTarget && n.hidden === nextHidden) return n;
if (!isTarget) {
return { ...n, hidden: nextHidden };
}
// Target parent: update collapsed flag + size. Dropping width/
// height would leave the node at its prior (possibly huge)
// dimensions after a collapse, leaving a gigantic empty card
// with no visible children.
return {
...n,
hidden: nextHidden,
data: isTarget ? { ...n.data, collapsed } : n.data,
data: { ...n.data, collapsed },
width: collapsed ? CHILD_DEFAULT_WIDTH : expandedSize.width,
height: collapsed ? CHILD_DEFAULT_HEIGHT : expandedSize.height,
};
}),
});

View File

@ -191,12 +191,14 @@ func TestWorkspaceUpdate_Collapsed(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Canvas "collapse team" flip — the handler must run the UPDATE
// to persist the flag, otherwise the UI state resets on reload.
// Canvas "collapse team" flip — the handler must run the UPSERT
// on canvas_layouts to persist the flag, otherwise the UI state
// resets on reload. `collapsed` lives on canvas_layouts, not
// workspaces (see 005_canvas_layouts.sql).
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
WithArgs("dddddddd-0005-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET collapsed").
mock.ExpectExec("INSERT INTO canvas_layouts .* collapsed").
WithArgs("dddddddd-0005-0000-0000-000000000000", true).
WillReturnResult(sqlmock.NewResult(0, 1))

View File

@ -29,6 +29,115 @@ const workspaceCreatePacingMs = 2000
// fires 39 goroutines that all hit Docker at once, causing timeouts (#1084).
const provisionConcurrency = 3
// Child grid layout constants — kept in sync with canvas-topology.ts on
// the client. Children laid on import use the same 2-column grid so the
// nested view is clean out of the box. Before this, YAML-declared
// canvas coords (absolute, horizontally fanned at y=180) produced an
// overlapping mess under the nested render (see screenshot in PR
// #1981 thread).
const (
childDefaultWidth = 240.0
childDefaultHeight = 130.0
childGutter = 14.0
parentHeaderPadding = 130.0
parentSidePadding = 16.0
childGridColumnCount = 2
)
// childSlot computes the child-relative position for the N-th sibling in
// a parent's 2-column grid. Matches defaultChildSlot in
// canvas-topology.ts exactly — change them together. Leaf-sized slots
// only; for variable-size siblings use childSlotInGrid below.
func childSlot(index int) (x, y float64) {
col := index % childGridColumnCount
row := index / childGridColumnCount
x = parentSidePadding + float64(col)*(childDefaultWidth+childGutter)
y = parentHeaderPadding + float64(row)*(childDefaultHeight+childGutter)
return
}
type nodeSize struct {
width, height float64
}
// sizeOfSubtree computes the bounding-box size for a workspace and its
// entire descendant tree as rendered by the canvas grid layout.
// Post-order: leaves return the CHILD_DEFAULT footprint; parents return
// the size that fits all direct children (which may themselves be
// parents with grandchildren). Matches the client's
// `subtreeSize` pass in canvas-topology.ts so the server can lay out
// org imports the same way the canvas will render them.
func sizeOfSubtree(ws OrgWorkspace) nodeSize {
if len(ws.Children) == 0 {
return nodeSize{childDefaultWidth, childDefaultHeight}
}
cols := childGridColumnCount
if len(ws.Children) < cols {
cols = len(ws.Children)
}
rows := (len(ws.Children) + cols - 1) / cols
childSizes := make([]nodeSize, len(ws.Children))
maxColW := 0.0
for i, c := range ws.Children {
childSizes[i] = sizeOfSubtree(c)
if childSizes[i].width > maxColW {
maxColW = childSizes[i].width
}
}
rowHeights := make([]float64, rows)
for i, cs := range childSizes {
row := i / cols
if cs.height > rowHeights[row] {
rowHeights[row] = cs.height
}
}
totalRowH := 0.0
for _, h := range rowHeights {
totalRowH += h
}
return nodeSize{
width: parentSidePadding*2 + maxColW*float64(cols) + childGutter*float64(cols-1),
height: parentHeaderPadding + totalRowH + childGutter*float64(rows-1) + parentSidePadding,
}
}
// childSlotInGrid computes the relative position of sibling `index`
// given all siblings' subtree sizes. Uniform column width (= max width
// across siblings), per-row max height, so a nested parent sibling
// pushes its row down without displacing the column grid. Matches the
// TS mirror in canvas-topology.ts.
func childSlotInGrid(index int, siblingSizes []nodeSize) (x, y float64) {
if len(siblingSizes) == 0 {
return parentSidePadding, parentHeaderPadding
}
cols := childGridColumnCount
if len(siblingSizes) < cols {
cols = len(siblingSizes)
}
rows := (len(siblingSizes) + cols - 1) / cols
maxColW := 0.0
for _, s := range siblingSizes {
if s.width > maxColW {
maxColW = s.width
}
}
rowHeights := make([]float64, rows)
for i, s := range siblingSizes {
row := i / cols
if s.height > rowHeights[row] {
rowHeights[row] = s.height
}
}
col := index % cols
row := index / cols
x = parentSidePadding + float64(col)*(maxColW+childGutter)
y = parentHeaderPadding
for r := 0; r < row; r++ {
y += rowHeights[r] + childGutter
}
return
}
// orgImportScheduleSQL is the upsert executed for every schedule during
// org/import. Extracted to a const so TestImport_OrgScheduleSQLShape can
// assert its shape without regex-scanning org.go (issue #24 follow-up).
@ -300,9 +409,12 @@ func (h *OrgHandler) Import(c *gin.Context) {
// Semaphore limits concurrent Docker provisioning (#1084).
provisionSem := make(chan struct{}, provisionConcurrency)
// Recursively create workspaces
// Recursively create workspaces. Root workspaces keep their YAML
// canvas coords; children are positioned by createWorkspaceTree
// using subtree-aware grid slots (children that are themselves
// parents get a bigger slot so they don't overflow into siblings).
for _, ws := range tmpl.Workspaces {
if err := h.createWorkspaceTree(ws, nil, tmpl.Defaults, orgBaseDir, &results, provisionSem); err != nil {
if err := h.createWorkspaceTree(ws, nil, ws.Canvas.X, ws.Canvas.Y, tmpl.Defaults, orgBaseDir, &results, provisionSem); err != nil {
createErr = err
break
}

View File

@ -21,7 +21,14 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
"github.com/google/uuid"
)
func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defaults OrgDefaults, orgBaseDir string, results *[]map[string]interface{}, provisionSem chan struct{}) error {
// createWorkspaceTree recursively materialises an OrgWorkspace (and its
// descendants) into the workspaces + canvas_layouts tables and kicks off
// Docker provisioning. absX/absY are THIS workspace's absolute canvas
// coordinates — roots inherit them from ws.Canvas, children receive
// parent.abs + childSlotInGrid(index, siblingSizes) computed by the
// caller. Storing already-absolute coords means a child that is itself
// a parent can simply compound the grid without any per-call math.
func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX, absY float64, defaults OrgDefaults, orgBaseDir string, results *[]map[string]interface{}, provisionSem chan struct{}) error {
// Apply defaults
runtime := ws.Runtime
if runtime == "" {
@ -88,26 +95,35 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa
ctx := context.Background()
// Org-template imports can drop dozens of nested workspaces onto the
// canvas at once. Letting them render expanded by default sprays
// child cards across the viewport (sibling workspaces spill below
// the parent before the user can orient themselves). Default every
// parent in the imported tree to collapsed — the parent card shows
// only its header + "N sub" badge until the user double-clicks to
// expand it. Leaf workspaces stay expanded (nothing to hide).
initialCollapsed := len(ws.Children) > 0
// Org-template imports default to expanded so children render
// visually nested inside their parent — matches the user's mental
// model ("all children should be in front of its parent"). The
// topology rescue heuristic lays any children whose YAML coords
// fall outside the computed parent bbox into a tidy 2-column grid
// (see canvas-topology.ts), so imports don't spray the viewport.
initialCollapsed := false
_, err := db.DB.ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, collapsed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, initialCollapsed)
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess)
if err != nil {
log.Printf("Org import: failed to create %s: %v", ws.Name, err)
return fmt.Errorf("failed to create %s: %w", ws.Name, err)
}
// Canvas layout with coordinates from YAML
if _, err := db.DB.ExecContext(ctx, `INSERT INTO canvas_layouts (workspace_id, x, y) VALUES ($1, $2, $3)`, id, ws.Canvas.X, ws.Canvas.Y); err != nil {
// Canvas layout — absX/absY were computed by the caller using the
// subtree-aware grid (childSlotInGrid) so a nested-parent child
// doesn't clip into its siblings. Raw YAML canvas coords are only
// consulted at the root: many templates predate the nested-parent
// model and author them as a flat horizontal row (y=180, x=100..1220),
// which overlaps chaotically once the cards render inside a parent
// container.
//
// `collapsed` lives on canvas_layouts (005_canvas_layouts.sql), not
// on workspaces; the UI-only flag is intentionally decoupled from
// the workspace row.
if _, err := db.DB.ExecContext(ctx, `INSERT INTO canvas_layouts (workspace_id, x, y, collapsed) VALUES ($1, $2, $3, $4)`, id, absX, absY, initialCollapsed); err != nil {
log.Printf("Org import: canvas layout insert failed for %s: %v", ws.Name, err)
}
@ -480,11 +496,24 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa
// Recurse into children. Brief pacing avoids overwhelming Docker when
// creating many containers in sequence; container provisioning runs in
// goroutines so the main createWorkspaceTree returns quickly.
for _, child := range ws.Children {
if err := h.createWorkspaceTree(child, &id, defaults, orgBaseDir, results, provisionSem); err != nil {
return err
// Children's abs coords = this.abs + childSlotInGrid(index, siblingSizes),
// with sibling sizes computed by sizeOfSubtree so a nested-parent
// child claims a bigger grid slot than a leaf sibling — no slot
// clipping across mixed leaf / parent siblings.
if len(ws.Children) > 0 {
siblingSizes := make([]nodeSize, len(ws.Children))
for i, c := range ws.Children {
siblingSizes[i] = sizeOfSubtree(c)
}
for i, child := range ws.Children {
slotX, slotY := childSlotInGrid(i, siblingSizes)
childAbsX := absX + slotX
childAbsY := absY + slotY
if err := h.createWorkspaceTree(child, &id, childAbsX, childAbsY, defaults, orgBaseDir, results, provisionSem); err != nil {
return err
}
time.Sleep(workspaceCreatePacingMs * time.Millisecond)
}
time.Sleep(workspaceCreatePacingMs * time.Millisecond)
}
return nil

View File

@ -191,8 +191,14 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
if collapsed, ok := body["collapsed"]; ok {
// `collapsed` is the canvas UI-only flag that hides descendants
// in the tree view (WorkspaceNode renders the parent as header-
// only). Persisting it here so the state survives reload.
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET collapsed = $2, updated_at = now() WHERE id = $1`, id, collapsed); err != nil {
// only). It lives on canvas_layouts (005_canvas_layouts.sql),
// not workspaces — UPSERT because workspaces created outside the
// canvas flow (e.g. workspace_handler Create before a layout row
// exists) may not have a canvas_layouts row yet.
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO canvas_layouts (workspace_id, collapsed) VALUES ($1, $2)
ON CONFLICT (workspace_id) DO UPDATE SET collapsed = EXCLUDED.collapsed
`, id, collapsed); err != nil {
log.Printf("Update collapsed error for %s: %v", id, err)
}
}