molecule-core/canvas/src/store/canvas.ts
Molecule AI Core-FE 1224f19cfc
Some checks failed
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 4s
feat(canvas): screen reader live announcements for canvas state changes
Issue: HIGH priority item from canvas accessibility audit (2026-05-09).
Screen reader users had no way to know when workspace status changed
— the canvas updated visually but no announcement was made.

Changes:
- canvas.ts: add `liveAnnouncement: string` + `setLiveAnnouncement` to
  CanvasState so the store can hold the current announcement text.
- canvas-events.ts: set `liveAnnouncement` in handleCanvasEvent for 6
  key status transitions: ONLINE, OFFLINE, PAUSED, DEGRADED, PROVISIONING,
  REMOVED, PROVISION_FAILED. Names are looked up from store nodes so
  announcements are human-readable ("Alpha is now online" not "ws-1").
  TASK_UPDATED and AGENT_MESSAGE are intentionally excluded — they fire
  on every heartbeat and would overwhelm the user.
- Canvas.tsx: subscribe to `liveAnnouncement` from the store; render a
  visually-hidden `aria-live="polite" aria-atomic="true"` region that
  speaks the announcement then clears it after 500 ms so the same
  message doesn't re-announce on re-render. Fallback still announces
  workspace count on initial load.
- canvas-events.test.ts: 12 new test cases covering announcement
  content for all 6 event types, empty/no-announcement cases, and
  payload-name fallback when a node isn't yet in the store.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:30:33 +00:00

1043 lines
41 KiB
TypeScript

import { create } from "zustand";
import {
type Node,
type Edge,
applyNodeChanges,
type NodeChange,
} from "@xyflow/react";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
import type { WorkspaceData, WSMessage } from "./socket";
import { handleCanvasEvent } from "./canvas-events";
import { markDeleted, wasRecentlyDeleted } from "./deleteTombstones";
import {
buildNodesAndEdges,
computeAutoLayout,
defaultChildSlot,
parentMinSizeFromChildren,
sortParentsBeforeChildren,
CHILD_DEFAULT_HEIGHT,
CHILD_DEFAULT_WIDTH,
PARENT_BOTTOM_PADDING,
PARENT_SIDE_PADDING,
} from "./canvas-topology";
/**
* Walk every parent node and bump its width/height (if explicitly set)
* so the union of its children's relative bboxes plus padding fits. A
* parent's size never shrinks via this path — only grows — because
* shrinking on resize would fight the user's own NodeResizer drag.
*/
function growParentsToFitChildren<T extends Record<string, unknown>>(
nodes: Node<T>[],
): Node<T>[] {
// Index children by parentId so the scan is O(n).
const childrenByParent = new Map<string, Node<T>[]>();
for (const n of nodes) {
if (!n.parentId) continue;
const arr = childrenByParent.get(n.parentId) ?? [];
arr.push(n);
childrenByParent.set(n.parentId, arr);
}
let changed = false;
const out = nodes.map((n) => {
const kids = childrenByParent.get(n.id);
if (!kids || kids.length === 0) return n;
// Collapsed parents intentionally render compact — skip the grow
// pass so their size isn't pushed back out by their hidden kids.
const nData = n.data as unknown as WorkspaceNodeData | undefined;
if (nData?.collapsed) return n;
let maxRight = 0;
let maxBottom = 0;
for (const k of kids) {
const w = (k.measured?.width ?? k.width ?? CHILD_DEFAULT_WIDTH) as number;
const h = (k.measured?.height ?? k.height ?? CHILD_DEFAULT_HEIGHT) as number;
maxRight = Math.max(maxRight, k.position.x + w);
maxBottom = Math.max(maxBottom, k.position.y + h);
}
const requiredW = maxRight + PARENT_SIDE_PADDING;
const requiredH = maxBottom + PARENT_BOTTOM_PADDING;
const currentW = (n.measured?.width ?? n.width ?? 0) as number;
const currentH = (n.measured?.height ?? n.height ?? 0) as number;
if (requiredW <= currentW && requiredH <= currentH) return n;
changed = true;
return {
...n,
width: Math.max(currentW, requiredW),
height: Math.max(currentH, requiredH),
};
});
return changed ? out : nodes;
}
// Re-export extracted types and functions so existing imports from "@/store/canvas" keep working
export { summarizeWorkspaceCapabilities } from "./canvas-capabilities";
export type { WorkspaceCapabilitySummary } from "./canvas-capabilities";
export interface WorkspaceNodeData extends Record<string, unknown> {
name: string;
status: string;
tier: number;
agentCard: Record<string, unknown> | null;
activeTasks: number;
collapsed: boolean;
role: string;
lastErrorRate: number;
lastSampleError: string;
url: string;
parentId: string | null;
currentTask: string;
runtime: string;
needsRestart: boolean;
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
budgetLimit: number | null;
/** Cumulative USD spend. Present when the platform tracks spend (issue #541). */
budgetUsed?: number | null;
/** Per-workspace provisioning-timeout override in milliseconds (#2054).
* Sourced server-side from the workspace's template manifest at provision
* time. null/absent = fall through to runtime profile + default in
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
* expectation without a canvas release. */
provisionTimeoutMs?: number | null;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;
y: number;
nodeId: string;
nodeData: WorkspaceNodeData;
}
interface CanvasState {
nodes: Node<WorkspaceNodeData>[];
edges: Edge[];
selectedNodeId: string | null;
panelTab: PanelTab;
dragOverNodeId: string | null;
contextMenu: ContextMenuState | null;
// Live width of the SidePanel in pixels. Only meaningful when
// selectedNodeId is non-null (panel visible). The Toolbar reads this
// to stay centred on the remaining canvas area instead of the full
// viewport, so the "Audit" / "Search" / "Settings" buttons don't get
// hidden behind the panel when a workspace is selected.
sidePanelWidth: number;
setSidePanelWidth: (w: number) => void;
// Whether the TemplatePalette left-drawer is open. Consumed by the
// Legend so it can shift right and avoid being hidden under the
// palette. Set by TemplatePalette's toggle button.
templatePaletteOpen: boolean;
setTemplatePaletteOpen: (open: boolean) => void;
hydrate: (workspaces: WorkspaceData[]) => void;
applyEvent: (msg: WSMessage) => void;
onNodesChange: (changes: NodeChange<Node<WorkspaceNodeData>>[]) => void;
savePosition: (nodeId: string, x: number, y: number) => void;
selectNode: (id: string | null) => void;
setPanelTab: (tab: PanelTab) => void;
getSelectedNode: () => Node<WorkspaceNodeData> | null;
updateNodeData: (id: string, data: Partial<WorkspaceNodeData>) => void;
restartWorkspace: (id: string) => Promise<void>;
removeNode: (id: string) => void;
/** Remove a node AND every descendant in one atomic update. Mirrors
* the server-side cascade — `DELETE /workspaces/:id?confirm=true`
* drops the row plus every descendant in one transaction. The
* caller (Canvas / DetailsTab delete handlers) used to call
* `removeNode(rootId)` and rely on per-descendant WORKSPACE_REMOVED
* WS events to clear the rest. When the WS is unhealthy those
* events never arrive and the children orphan to the root until a
* manual page refresh — `removeSubtree` makes the cascade
* WS-independent. */
removeSubtree: (rootId: string) => void;
setDragOverNode: (id: string | null) => void;
nestNode: (draggedId: string, targetId: string | null) => Promise<void>;
isDescendant: (ancestorId: string, nodeId: string) => boolean;
/** Re-order siblings in z-index space. `direction = +1` sends the node
* one step forward among its parent's children (or among canvas
* roots); -1 sends it one step back. Figma Cmd+]/[ parity. */
bumpZOrder: (nodeId: string, direction: 1 | -1) => void;
/** Re-parent many nodes at once, preserving each node's absolute
* position. Lucidchart pattern: drag a selection into a frame and
* the inter-node layout stays intact. Used when the primary dragged
* node of a multi-select drag triggers a nest confirmation. */
batchNest: (nodeIds: string[], targetId: string | null) => Promise<void>;
/** Run the parent auto-grow pass once. Canvas.onNodeDragStop calls
* this so a drag that pushed a child past the parent edge commits
* the parent grow on release (commit-on-release pattern). */
growParentsToFitChildren: () => void;
/** Re-layout a parent's children to the default 2-column grid. Used
* by the "Arrange children" context-menu command so users can rescue
* out-of-bounds children on demand — topology no longer does it
* automatically (P3.12 opt-in rescue). */
arrangeChildren: (parentId: string) => void;
/** Toggle the collapsed flag on a parent and hide/show every
* descendant so the card renders as a compact header-only frame.
* Miro "frame outline view" analog. */
setCollapsed: (parentId: string, collapsed: boolean) => void;
openContextMenu: (menu: ContextMenuState) => void;
closeContextMenu: () => void;
// Pending delete confirmation — lives in the store (not inside ContextMenu's
// local state) so the confirm dialog survives ContextMenu unmounting. The
// ContextMenu's portal-rendered dialog used to race with its outside-click
// handler: clicking Confirm registered as "outside", closed the menu, and
// unmounted the dialog before its onClick fired. Hoisting the state fixes
// that — see fix/context-menu-delete-race.
pendingDelete:
| { id: string; name: string; hasChildren: boolean; children: { id: string; name: string }[] }
| null;
setPendingDelete: (
v: { id: string; name: string; hasChildren: boolean; children: { id: string; name: string }[] } | null
) => void;
/** Node IDs whose DELETE request is in flight. Populated the moment
* the user confirms a cascade delete; drained as WORKSPACE_REMOVED
* events strip the nodes (or all-at-once on request failure). Lets
* the canvas render the "don't touch — something is happening"
* treatment (dim + non-draggable) during the network round trip
* and the server-side cascade, matching the deploy-lock UX. */
deletingIds: Set<string>;
beginDelete: (ids: Iterable<string>) => void;
endDelete: (ids: Iterable<string>) => void;
searchOpen: boolean;
setSearchOpen: (open: boolean) => void;
viewport: { x: number; y: number; zoom: number };
setViewport: (v: { x: number; y: number; zoom: number }) => void;
saveViewport: (x: number, y: number, zoom: number) => void;
// ── Batch selection (Phase 20.3) ─────────────────────────────────────────
selectedNodeIds: Set<string>;
toggleNodeSelection: (id: string) => void;
clearSelection: () => void;
batchRestart: () => Promise<void>;
batchPause: () => Promise<void>;
batchDelete: () => Promise<void>;
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>;
/** WebSocket connection status — drives the live indicator in the Toolbar. */
wsStatus: "connected" | "connecting" | "disconnected";
setWsStatus: (status: "connected" | "connecting" | "disconnected") => void;
/** Hydration error message — set when initial canvas load fails. Null when no error. */
hydrationError: string | null;
setHydrationError: (error: string | null) => void;
// ── A2A topology overlay (issue #744) ─────────────────────────────────────
/** Directed delegation edges shown as an overlay on the canvas (separate from topology edges). */
a2aEdges: Edge[];
setA2AEdges: (edges: Edge[]) => void;
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
showA2AEdges: boolean;
setShowA2AEdges: (show: boolean) => void;
/** Screen-reader announcement text. Set by handleCanvasEvent on significant
* status changes; consumed and cleared by the aria-live region in Canvas.tsx
* so the same announcement doesn't re-fire on re-render. */
liveAnnouncement: string;
setLiveAnnouncement: (msg: string) => void;
}
export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes: [],
edges: [],
selectedNodeId: null,
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
sidePanelWidth: 480, // matches SIDEPANEL_DEFAULT_WIDTH in SidePanel.tsx
setSidePanelWidth: (w) => set({ sidePanelWidth: w }),
templatePaletteOpen: false,
setTemplatePaletteOpen: (open) => set({ templatePaletteOpen: open }),
// Batch selection
selectedNodeIds: new Set<string>(),
toggleNodeSelection: (id) => {
const prev = get().selectedNodeIds;
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
set({ selectedNodeIds: next });
},
clearSelection: () => set({ selectedNodeIds: new Set<string>() }),
batchRestart: async () => {
const ids = Array.from(get().selectedNodeIds);
const results = await Promise.allSettled(
ids.map((id) => api.post(`/workspaces/${id}/restart`))
);
const failed: string[] = [];
results.forEach((r, i) => {
if (r.status === "fulfilled") {
get().updateNodeData(ids[i], { needsRestart: false });
} else {
failed.push(ids[i]);
}
});
// Keep failed IDs selected so the user can retry; drop the successful ones.
set({ selectedNodeIds: new Set(failed) });
if (failed.length > 0) {
throw new Error(`${failed.length}/${ids.length} restart(s) failed`);
}
},
batchPause: async () => {
const ids = Array.from(get().selectedNodeIds);
const results = await Promise.allSettled(
ids.map((id) => api.post(`/workspaces/${id}/pause`))
);
const failed: string[] = [];
results.forEach((r, i) => {
if (r.status !== "fulfilled") failed.push(ids[i]);
});
set({ selectedNodeIds: new Set(failed) });
if (failed.length > 0) {
throw new Error(`${failed.length}/${ids.length} pause(s) failed`);
}
},
batchDelete: async () => {
const ids = Array.from(get().selectedNodeIds);
const results = await Promise.allSettled(
ids.map((id) => api.del(`/workspaces/${id}`))
);
const failed: string[] = [];
results.forEach((r, i) => {
if (r.status === "fulfilled") {
get().removeNode(ids[i]);
} else {
failed.push(ids[i]);
}
});
// Keep failed IDs selected so the user can retry; the successful ones
// were already removed from `nodes` above.
set({ selectedNodeIds: new Set(failed) });
if (failed.length > 0) {
throw new Error(`${failed.length}/${ids.length} delete(s) failed`);
}
},
wsStatus: "connecting",
setWsStatus: (status) => set({ wsStatus: status }),
hydrationError: null,
setHydrationError: (error) => set({ hydrationError: error }),
// A2A overlay — default on, persisted to localStorage
a2aEdges: [],
setA2AEdges: (edges) => set({ a2aEdges: edges }),
showA2AEdges:
typeof window !== "undefined"
? localStorage.getItem("molecule:show-a2a-edges") !== "false"
: true,
setShowA2AEdges: (show) => {
set({ showA2AEdges: show });
if (typeof window !== "undefined") {
localStorage.setItem("molecule:show-a2a-edges", String(show));
}
},
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
viewport: { x: 0, y: 0, zoom: 1 },
selectNode: (id) => set({ selectedNodeId: id }),
openContextMenu: (menu) => set({ contextMenu: menu }),
closeContextMenu: () => set({ contextMenu: null }),
pendingDelete: null,
setPendingDelete: (v) => set({ pendingDelete: v }),
deletingIds: new Set<string>(),
beginDelete: (ids) => {
const next = new Set(get().deletingIds);
for (const id of ids) next.add(id);
set({ deletingIds: next });
},
endDelete: (ids) => {
const next = new Set(get().deletingIds);
for (const id of ids) next.delete(id);
set({ deletingIds: next });
},
searchOpen: false,
setSearchOpen: (open) => set({ searchOpen: open }),
agentMessages: {},
consumeAgentMessages: (workspaceId) => {
const msgs = get().agentMessages[workspaceId] || [];
if (msgs.length > 0) {
const { agentMessages } = get();
const { [workspaceId]: _, ...rest } = agentMessages;
set({ agentMessages: rest });
}
return msgs;
},
setViewport: (v) => set({ viewport: v }),
saveViewport: async (x, y, zoom) => {
set({ viewport: { x, y, zoom } });
try {
await api.put(`/canvas/viewport`, { x, y, zoom });
} catch {
// Non-critical — viewport save failure doesn't block user
}
},
setPanelTab: (tab) => set({ panelTab: tab }),
setDragOverNode: (id) => set({ dragOverNodeId: id }),
batchNest: async (nodeIds, targetId) => {
if (nodeIds.length === 0) return;
// Selection-roots filter: if the user selected both A and A's
// descendant B and dragged the pair into T, the intent is "move
// the subtree" — B should stay under A, not become a sibling of
// A under T. Drop every selected node whose ancestor is also
// selected; those will follow their ancestor via React Flow's
// parent-of binding automatically.
const selectedSet = new Set(nodeIds);
const { nodes: before, edges: beforeEdges } = get();
const byId = new Map(before.map((n) => [n.id, n]));
const rootsOnly: string[] = [];
for (const id of nodeIds) {
let cursor = byId.get(id)?.data.parentId ?? null;
let hasSelectedAncestor = false;
// Seen-set guards against a corrupt parentId cycle. Shouldn't
// happen with a healthy backend — nestNode itself blocks cycles
// via isDescendant — but this walk is user-triggered and the
// cost of the guard is one set allocation per selected node.
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
if (selectedSet.has(cursor)) {
hasSelectedAncestor = true;
break;
}
cursor = byId.get(cursor)?.data.parentId ?? null;
}
if (!hasSelectedAncestor) rootsOnly.push(id);
}
if (rootsOnly.length === 0) return;
if (rootsOnly.length === 1) {
await get().nestNode(rootsOnly[0], targetId);
return;
}
// Batch path: do all state math against one snapshot so every
// selected node sees the same "before" world, commit one set(),
// then fire every PATCH in parallel. Previously this called
// nestNode sequentially, which cost 2N round-trips (parent_id +
// x/y) strictly serialized; now it's 1 round-trip per node, all
// in flight at once. For a typical 3-5 node selection on a
// ~200ms link this drops the perceived re-parent latency from
// ~2s to ~200ms.
const absOf = (id: string | null | undefined): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const depthOf = (id: string | null | undefined): number => {
let d = 0;
let cursor: string | null | undefined = id;
while (cursor) {
const n = byId.get(cursor);
if (!n) break;
cursor = n.data.parentId;
d += 1;
}
return d;
};
const newParentAbs = absOf(targetId);
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
interface Plan {
id: string;
newRelative: { x: number; y: number };
draggedAbs: { x: number; y: number };
depthDelta: number;
}
const plan: Plan[] = [];
const movedIds = new Set<string>();
// Filter out nodes that would be invalid targets / no-ops.
for (const id of rootsOnly) {
const dragged = byId.get(id);
if (!dragged) continue;
const currentParentId = dragged.data.parentId;
if (currentParentId === targetId) continue;
// Can't nest into yourself or your own descendant.
if (targetId && get().isDescendant(id, targetId)) continue;
const oldParentAbs = absOf(currentParentId);
const draggedAbs = {
x: dragged.position.x + oldParentAbs.x,
y: dragged.position.y + oldParentAbs.y,
};
const newRelative = {
x: draggedAbs.x - newParentAbs.x,
y: draggedAbs.y - newParentAbs.y,
};
const oldOwnDepth =
dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
plan.push({
id,
newRelative,
draggedAbs,
depthDelta: newOwnDepth - oldOwnDepth,
});
movedIds.add(id);
// Every descendant of a moved node also shifts by the same delta
// so grandchildren don't fall behind their re-parented ancestor.
const bfs = [id];
while (bfs.length) {
const head = bfs.shift()!;
for (const n of before) {
if (n.data.parentId === head && !movedIds.has(n.id)) {
movedIds.add(n.id);
bfs.push(n.id);
}
}
}
}
if (plan.length === 0) return;
const planById = new Map(plan.map((p) => [p.id, p]));
// One optimistic set() covers every re-parent + every descendant
// zIndex shift; no further state mutations before the PATCHes come
// back (failed PATCHes roll back individual nodes below).
set({
nodes: before.map((n) => {
const p = planById.get(n.id);
if (p) {
return {
...n,
position: p.newRelative,
parentId: targetId ?? undefined,
zIndex: newOwnDepth,
data: { ...n.data, parentId: targetId },
};
}
// Descendant of a moved node — shift zIndex only. Find the
// nearest ancestor in `plan` (walking up parents) to know
// which depthDelta applies.
if (movedIds.has(n.id)) {
let cursor: string | null | undefined = n.data.parentId;
while (cursor) {
const anc = planById.get(cursor);
if (anc) {
if (anc.depthDelta === 0) break;
return { ...n, zIndex: (n.zIndex ?? 0) + anc.depthDelta };
}
cursor = byId.get(cursor)?.data.parentId ?? null;
}
return n;
}
return n;
}),
edges: beforeEdges.filter(
(e) => !movedIds.has(e.source) && !movedIds.has(e.target),
),
});
// Keep parents before children in the array (same invariant
// nestNode enforces). Needed after multi-select re-parent because
// the selection order is user-driven.
set({ nodes: sortParentsBeforeChildren(get().nodes) });
// Fire every PATCH in parallel. Individual failures roll back just
// that node (others remain committed, matching the single-node
// rollback behaviour in nestNode).
const results = await Promise.allSettled(
plan.map((p) =>
api.patch(`/workspaces/${p.id}`, {
parent_id: targetId,
x: p.draggedAbs.x,
y: p.draggedAbs.y,
}),
),
);
const rolledBack: string[] = [];
for (let i = 0; i < results.length; i++) {
if (results[i].status === "rejected") rolledBack.push(plan[i].id);
}
if (rolledBack.length > 0) {
const rollbackSet = new Set(rolledBack);
set({
nodes: get().nodes.map((n) => {
if (!rollbackSet.has(n.id)) return n;
const original = byId.get(n.id);
if (!original) return n;
return {
...n,
position: original.position,
parentId: original.parentId,
zIndex: original.zIndex,
data: { ...n.data, parentId: original.data.parentId },
};
}),
});
// Surface the partial failure — silent rollback would otherwise
// leave the canvas in a state the user can't explain ("I dragged
// 5 cards, 3 moved and 2 snapped back?"). Cap the name list so a
// 50-node partial failure doesn't overflow the toast container.
const NAMES_IN_TOAST = 3;
const names = rolledBack
.map((id) => byId.get(id)?.data.name)
.filter((n): n is string => Boolean(n));
const shown = names.slice(0, NAMES_IN_TOAST).join(", ");
const overflow = names.length - NAMES_IN_TOAST;
const listFragment = shown
? overflow > 0
? `: ${shown} and ${overflow} more`
: `: ${shown}`
: "";
showToast(
`Could not re-parent ${rolledBack.length} of ${plan.length} workspace${plan.length === 1 ? "" : "s"}${listFragment}`,
"error",
);
}
},
bumpZOrder: (nodeId, direction) => {
const { nodes } = get();
const target = nodes.find((n) => n.id === nodeId);
if (!target) return;
// Siblings share parentId; re-rank them by their current zIndex (then
// insertion order) so we can SWAP the target with its neighbour in
// the bump direction rather than drifting zIndex up/down unbounded.
// This keeps sibling zIndex values within `[baseDepth, baseDepth+N)`,
// which is what findDropTarget's tiebreakers assume.
const siblings = nodes
.filter((n) => n.data.parentId === target.data.parentId)
.slice()
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
if (siblings.length < 2) return;
const idx = siblings.findIndex((n) => n.id === nodeId);
const neighbourIdx = idx + direction;
if (neighbourIdx < 0 || neighbourIdx >= siblings.length) return;
const neighbour = siblings[neighbourIdx];
const targetZ = target.zIndex ?? 0;
const neighbourZ = neighbour.zIndex ?? 0;
// Ensure a visible swap even when both had identical zIndex (fresh
// topology: every sibling starts at zIndex=depth). Nudge the
// neighbour one step the other way so the pair stays adjacent.
const resolvedTargetZ = targetZ === neighbourZ ? targetZ + direction : neighbourZ;
const resolvedNeighbourZ = targetZ === neighbourZ ? targetZ : targetZ;
set({
nodes: nodes.map((n) => {
if (n.id === nodeId) return { ...n, zIndex: resolvedTargetZ };
if (n.id === neighbour.id) return { ...n, zIndex: resolvedNeighbourZ };
return n;
}),
});
},
isDescendant: (ancestorId, nodeId) => {
const { nodes } = get();
let current = nodes.find((n) => n.id === nodeId);
while (current?.data.parentId) {
if (current.data.parentId === ancestorId) return true;
current = nodes.find((n) => n.id === current?.data.parentId);
}
return false;
},
nestNode: async (draggedId, targetId) => {
const { nodes, edges } = get();
const dragged = nodes.find((n) => n.id === draggedId);
if (!dragged) return;
const currentParentId = dragged.data.parentId;
if (currentParentId === targetId) return;
// Compute each ancestor's absolute position by walking up the
// parentId chain. We need this to translate the dragged node's
// `position` (relative to its current parent when nested) between
// the old and new coordinate spaces so the card doesn't visually
// jump on nest/unnest.
const absOf = (id: string | null): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const oldParentAbs = absOf(currentParentId);
const newParentAbs = absOf(targetId);
const draggedAbs = {
x: dragged.position.x + oldParentAbs.x,
y: dragged.position.y + oldParentAbs.y,
};
const newRelative = {
x: draggedAbs.x - newParentAbs.x,
y: draggedAbs.y - newParentAbs.y,
};
const newEdges = edges.filter(
(e) => e.source !== draggedId && e.target !== draggedId,
);
// Depth walk so zIndex gets bumped correctly on nest/unnest
// (children render above their new ancestor chain). `depthOf(null)`
// returns 0; for any non-null cursor we count one hop per ancestor.
const depthOf = (id: string | null | undefined): number => {
let d = 0;
let cursor: string | null | undefined = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
cursor = n.data.parentId;
d += 1;
}
return d;
};
const newOwnDepth = targetId ? depthOf(targetId) + 1 : 0;
const oldOwnDepth = dragged.zIndex ?? depthOf(currentParentId) + (currentParentId ? 1 : 0);
const depthDelta = newOwnDepth - oldOwnDepth;
// Collect every descendant of the dragged node so we can shift their
// zIndex by the same depthDelta — otherwise grandchildren stay at
// their old depth zIndex after the move and render below ancestors
// they just joined. BFS to avoid stack surprises on deep hierarchies.
const movedIds = new Set<string>([draggedId]);
const bfsQueue = [draggedId];
while (bfsQueue.length) {
const head = bfsQueue.shift()!;
for (const n of nodes) {
if (n.data.parentId === head && !movedIds.has(n.id)) {
movedIds.add(n.id);
bfsQueue.push(n.id);
}
}
}
// When a child leaves its parent, clear the parent's explicit
// width/height. growParentsToFitChildren is grow-only so it can't
// shrink on its own; without this, a parent that auto-grew to
// contain the dragged child stays at that size after un-nest,
// leaving a large empty frame. React Flow then measures the new
// size from the card's own min-width/min-height CSS.
const shrinkOldParent = !!currentParentId && targetId !== currentParentId;
set({
nodes: nodes.map((n) => {
if (n.id === draggedId) {
return {
...n,
position: newRelative,
parentId: targetId ?? undefined,
zIndex: newOwnDepth,
data: { ...n.data, parentId: targetId },
};
}
if (shrinkOldParent && n.id === currentParentId) {
const { width: _w, height: _h, ...rest } = n;
void _w; void _h;
return rest as typeof n;
}
if (movedIds.has(n.id) && depthDelta !== 0) {
return { ...n, zIndex: (n.zIndex ?? 0) + depthDelta };
}
return n;
}),
edges: newEdges,
});
// React Flow requires parents before children in the array. Without
// this re-sort a newly-nested child can end up ahead of its new
// parent, which makes RF log "Parent node not found" and render the
// child at canvas-absolute coords (far outside the parent, which
// is the flash-bug the user just flagged).
set({ nodes: sortParentsBeforeChildren(get().nodes) });
try {
// One round-trip per nest: the /workspaces/:id PATCH handler
// accepts parent_id + x + y in a single body. The absolute x/y
// is what the DB stores as canonical (matches savePosition
// elsewhere), so reload renders the same place regardless of
// which parent the child was under at save time.
await api.patch(`/workspaces/${draggedId}`, {
parent_id: targetId,
x: draggedAbs.x,
y: draggedAbs.y,
});
} catch {
set({
nodes: get().nodes.map((n) =>
n.id === draggedId
? {
...n,
position: dragged.position,
parentId: currentParentId ?? undefined,
data: { ...n.data, parentId: currentParentId },
}
: n,
),
edges,
});
}
},
getSelectedNode: () => {
const { nodes, selectedNodeId } = get();
if (!selectedNodeId) return null;
return nodes.find((n) => n.id === selectedNodeId) ?? null;
},
updateNodeData: (id, data) => {
set({
nodes: get().nodes.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...data } } : n
),
});
},
restartWorkspace: async (id) => {
await api.post(`/workspaces/${id}/restart`);
get().updateNodeData(id, { needsRestart: false });
},
removeNode: (id) => {
const { nodes, edges, selectedNodeId } = get();
// Re-parent children to the deleted node's parent (or root).
// Children are first-class RF nodes now — we just re-point their
// parentId (both RF's native field and our data mirror). No hidden
// flag is toggled because cards are always visible.
const deletedNode = nodes.find((n) => n.id === id);
const parentOfDeleted = deletedNode?.data.parentId ?? null;
set({
nodes: nodes
.filter((n) => n.id !== id)
.map((n) =>
n.data.parentId === id
? {
...n,
parentId: parentOfDeleted ?? undefined,
data: { ...n.data, parentId: parentOfDeleted },
}
: n
),
edges: edges.filter((e) => e.source !== id && e.target !== id),
selectedNodeId: selectedNodeId === id ? null : selectedNodeId,
});
},
removeSubtree: (rootId) => {
const { nodes, edges, selectedNodeId } = get();
// Build a parentId → childIds index once so the descent is O(N),
// not O(N · depth). The store typically holds <500 nodes; even
// doing a linear scan per parent would be fine, but the index
// keeps the cost predictable as orgs grow.
const childrenByParent = new Map<string, string[]>();
for (const n of nodes) {
const p = n.data.parentId ?? null;
if (p === null) continue;
const arr = childrenByParent.get(p);
if (arr) arr.push(n.id);
else childrenByParent.set(p, [n.id]);
}
const removed = new Set<string>([rootId]);
const stack = [rootId];
while (stack.length) {
const cur = stack.pop()!;
const kids = childrenByParent.get(cur);
if (!kids) continue;
for (const k of kids) {
if (!removed.has(k)) {
removed.add(k);
stack.push(k);
}
}
}
// Tombstone removed ids so an in-flight GET /workspaces can't
// resurrect them via hydrate (#2069).
markDeleted(removed);
set({
nodes: nodes.filter((n) => !removed.has(n.id)),
edges: edges.filter((e) => !removed.has(e.source) && !removed.has(e.target)),
selectedNodeId:
selectedNodeId !== null && removed.has(selectedNodeId)
? null
: selectedNodeId,
});
},
hydrate: (workspaces: WorkspaceData[]) => {
// Drop ids tombstoned by a recent removeSubtree (#2069 — stale
// in-flight GET /workspaces).
const live = workspaces.filter((w) => !wasRecentlyDeleted(w.id));
const layoutOverrides = computeAutoLayout(live);
// Carry the live measured/grown parent sizes from the existing
// store into the rebuild. buildNodesAndEdges runs an auto-rescue
// pass on each child to detach orphans whose stored relative
// position falls outside the parent bbox — without the live
// size, the bbox is the initial grid-derived minimum, which
// false-flags any child the user has dragged into the
// user-grown area. Periodic rehydrate (socket.ts health check,
// 30s) was reasserting the rescue against legitimate user
// placements, causing the "child jumps to weird location, then
// settles" symptom.
const current = get().nodes;
const currentParentSizes = new Map<string, { width: number; height: number }>();
for (const n of current) {
const w = (n.measured?.width ?? n.width) as number | undefined;
const h = (n.measured?.height ?? n.height) as number | undefined;
if (typeof w === "number" && typeof h === "number") {
currentParentSizes.set(n.id, { width: w, height: h });
}
}
const { nodes, edges } = buildNodesAndEdges(
live,
layoutOverrides,
currentParentSizes,
);
set({ nodes, edges });
for (const [nodeId, { x, y }] of layoutOverrides) {
api.patch(`/workspaces/${nodeId}`, { x, y }).catch(() => {});
}
},
applyEvent: (msg: WSMessage) => {
handleCanvasEvent(msg, get, set);
},
onNodesChange: (changes) => {
const next = applyNodeChanges(changes, get().nodes);
// Parent auto-grow is intentionally conservative. Running
// growParentsToFitChildren on every change (including the dozens of
// position updates emitted during a single drag) caused the
// "edge-chase" artifact tldraw documented — as the parent grows in
// response to the child near its edge, the child's relative
// position becomes valid again and the grow stops mid-drag, only to
// resume on the next tick. Commit-on-release: only run grow when a
// change set contains a `dimensions` change (NodeResizer commit),
// not on pure `position` changes. Drag-stop grow is handled
// explicitly in Canvas.onNodeDragStop via growOnce().
const hasDimensionChange = changes.some((c) => c.type === "dimensions");
set({ nodes: hasDimensionChange ? growParentsToFitChildren(next) : next });
},
growParentsToFitChildren: () => {
set({ nodes: growParentsToFitChildren(get().nodes) });
},
setCollapsed: (parentId, collapsed) => {
const { nodes } = get();
// Step 1 — apply the new collapsed flag on the target.
const updatedCollapsed = new Map<string, boolean>();
for (const n of nodes) {
updatedCollapsed.set(
n.id,
n.id === parentId ? collapsed : !!n.data.collapsed,
);
}
// Step 2 — index children once so the visibility pass is O(n), not
// O(n·d). Walk roots downward, inheriting `hiddenBecauseAncestor`
// so a node is hidden iff ANY ancestor in the chain is collapsed.
// This matches canvas-topology.buildNodesAndEdges so setCollapsed
// and hydrate produce identical node.hidden flags — no drift when
// the server pushes a fresh snapshot mid-session.
const childrenByParent = new Map<string | null, string[]>();
for (const n of nodes) {
const p = n.data.parentId ?? null;
const arr = childrenByParent.get(p) ?? [];
arr.push(n.id);
childrenByParent.set(p, arr);
}
const hiddenById = new Map<string, boolean>();
const stack: Array<{ id: string; hidden: boolean }> = (
childrenByParent.get(null) ?? []
).map((id) => ({ id, hidden: false }));
while (stack.length) {
const { id, hidden } = stack.pop()!;
hiddenById.set(id, hidden);
const isCollapsed = updatedCollapsed.get(id) ?? false;
for (const childId of childrenByParent.get(id) ?? []) {
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: { ...n.data, collapsed },
width: collapsed ? CHILD_DEFAULT_WIDTH : expandedSize.width,
height: collapsed ? CHILD_DEFAULT_HEIGHT : expandedSize.height,
};
}),
});
},
arrangeChildren: (parentId) => {
const { nodes } = get();
const kids = nodes
.filter((n) => n.parentId === parentId)
.sort((a, b) => (a.data.name || "").localeCompare(b.data.name || ""));
if (kids.length === 0) return;
const slotByKid = new Map<string, { x: number; y: number }>();
kids.forEach((k, i) => slotByKid.set(k.id, defaultChildSlot(i)));
// Absolute position of the parent, walking the full ancestor chain.
// Required for a correct PATCH payload when the parent itself is
// nested — `parent.position` is RELATIVE to its own parent, so a
// naive `slot + parent.position` would store parent-local coords
// as if they were absolute and corrupt the workspace on reload.
const absOf = (id: string | null | undefined): { x: number; y: number } => {
let sum = { x: 0, y: 0 };
let cursor: string | null | undefined = id;
while (cursor) {
const n = nodes.find((nn) => nn.id === cursor);
if (!n) break;
sum = { x: sum.x + n.position.x, y: sum.y + n.position.y };
cursor = n.data.parentId;
}
return sum;
};
const parentAbs = absOf(parentId);
set({
nodes: nodes.map((n) => {
const slot = slotByKid.get(n.id);
return slot ? { ...n, position: slot } : n;
}),
});
for (const k of kids) {
const slot = slotByKid.get(k.id)!;
const absX = slot.x + parentAbs.x;
const absY = slot.y + parentAbs.y;
api.patch(`/workspaces/${k.id}`, { x: absX, y: absY }).catch((e) => {
console.warn(`arrangeChildren: failed to persist position for ${k.id}`, e);
});
}
},
savePosition: async (nodeId: string, x: number, y: number) => {
try {
await api.patch(`/workspaces/${nodeId}`, { x, y });
} catch {
// Non-critical — position save failure doesn't block user
}
},
}));