forked from molecule-ai/molecule-core
- Adds optional Budget limit (USD) numeric field to CreateWorkspaceDialog; blank = null (unlimited), populated = parsed float sent as budget_limit in POST /workspaces body - Adds budget_limit field to DetailsTab edit form; saves via PATCH /workspaces/:id; pre-fills from current WorkspaceNodeData - Shows 'Budget limit exceeded' warning badge when budgetUsed > budgetLimit (forward-compatible — badge hidden when budgetUsed is absent) - Extends WorkspaceData, WorkspaceNodeData, and buildNodesAndEdges to carry budgetLimit / budgetUsed fields ready for backend hydration (issue #541 BE PR) - Ships 22 new tests across CreateWorkspaceDialog and BudgetLimit.DetailsTab suites (575 total, all passing); npm run build clean; 'use client' grep empty API shape confirmed from workspace.go and CreateWorkspacePayload struct: field name: budget_limit | type: number | null | units: USD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
252 lines
8.1 KiB
TypeScript
252 lines
8.1 KiB
TypeScript
import { create } from "zustand";
|
|
import {
|
|
type Node,
|
|
type Edge,
|
|
applyNodeChanges,
|
|
type NodeChange,
|
|
} from "@xyflow/react";
|
|
import { api } from "@/lib/api";
|
|
import type { WorkspaceData, WSMessage } from "./socket";
|
|
import { handleCanvasEvent } from "./canvas-events";
|
|
import { buildNodesAndEdges, computeAutoLayout } from "./canvas-topology";
|
|
|
|
// 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;
|
|
}
|
|
|
|
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity";
|
|
|
|
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;
|
|
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;
|
|
setDragOverNode: (id: string | null) => void;
|
|
nestNode: (draggedId: string, targetId: string | null) => Promise<void>;
|
|
isDescendant: (ancestorId: string, nodeId: string) => boolean;
|
|
openContextMenu: (menu: ContextMenuState) => void;
|
|
closeContextMenu: () => 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;
|
|
/** Agent-pushed messages keyed by workspace ID. ChatTab consumes and clears these. */
|
|
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>>;
|
|
consumeAgentMessages: (workspaceId: string) => Array<{ id: string; content: string; timestamp: string }>;
|
|
/** 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;
|
|
}
|
|
|
|
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
|
nodes: [],
|
|
edges: [],
|
|
selectedNodeId: null,
|
|
panelTab: "chat",
|
|
dragOverNodeId: null,
|
|
contextMenu: null,
|
|
wsStatus: "connecting",
|
|
setWsStatus: (status) => set({ wsStatus: status }),
|
|
hydrationError: null,
|
|
setHydrationError: (error) => set({ hydrationError: error }),
|
|
|
|
viewport: { x: 0, y: 0, zoom: 1 },
|
|
|
|
selectNode: (id) => set({ selectedNodeId: id }),
|
|
openContextMenu: (menu) => set({ contextMenu: menu }),
|
|
closeContextMenu: () => set({ contextMenu: null }),
|
|
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 }),
|
|
|
|
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 currentParentId = nodes.find((n) => n.id === draggedId)?.data.parentId ?? null;
|
|
|
|
// No change needed
|
|
if (currentParentId === targetId) return;
|
|
|
|
// Optimistic update:
|
|
// - Set parentId in data
|
|
// - Hide child nodes (they render inside parent WorkspaceNode)
|
|
// - Remove all edges involving the dragged node
|
|
const newEdges = edges.filter(
|
|
(e) => e.source !== draggedId && e.target !== draggedId
|
|
);
|
|
|
|
set({
|
|
nodes: nodes.map((n) =>
|
|
n.id === draggedId
|
|
? {
|
|
...n,
|
|
hidden: !!targetId, // Hide if becoming a child, show if un-nesting
|
|
data: { ...n.data, parentId: targetId },
|
|
}
|
|
: n
|
|
),
|
|
edges: newEdges,
|
|
});
|
|
|
|
// Persist to API
|
|
try {
|
|
await api.patch(`/workspaces/${draggedId}`, { parent_id: targetId });
|
|
} catch {
|
|
// Revert on failure
|
|
set({
|
|
nodes: get().nodes.map((n) =>
|
|
n.id === draggedId
|
|
? {
|
|
...n,
|
|
hidden: !!currentParentId,
|
|
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)
|
|
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,
|
|
hidden: !!parentOfDeleted,
|
|
data: { ...n.data, parentId: parentOfDeleted },
|
|
}
|
|
: n
|
|
),
|
|
edges: edges.filter((e) => e.source !== id && e.target !== id),
|
|
selectedNodeId: selectedNodeId === id ? null : selectedNodeId,
|
|
});
|
|
},
|
|
|
|
hydrate: (workspaces: WorkspaceData[]) => {
|
|
const layoutOverrides = computeAutoLayout(workspaces);
|
|
const { nodes, edges } = buildNodesAndEdges(workspaces, layoutOverrides);
|
|
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) => {
|
|
set({
|
|
nodes: applyNodeChanges(changes, get().nodes),
|
|
});
|
|
},
|
|
|
|
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
|
|
}
|
|
},
|
|
}));
|