feat(collab): add frontend collaboration editing and edge-case hardening

Introduce an opt-in beta collaboration mode on the flow canvas that emits
local graph operation batches over the collab WebSocket, applies remote
forward_ops without full-flow autosave, and shows active collaborators.
Add operation diff/adapter helpers, collaboration-aware save flushing,
and coverage for drag, delete, paste/import, undo/redo, metadata, and
reload boundaries, including clearing stale undo history after remote ops.
This commit is contained in:
Hamza Rashid
2026-05-31 03:59:43 +00:00
parent eceffd9d48
commit 5a8fd170a2
22 changed files with 1689 additions and 83 deletions

View File

@ -0,0 +1,127 @@
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { AllNodeType, EdgeType } from "@/types/flow";
import { applyRemoteFlowOperations } from "../flow-operation-adapter";
const nodeA = {
id: "a",
type: "genericNode",
position: { x: 0, y: 0 },
data: { id: "a", type: "TextInput", node: {} },
} as AllNodeType;
const nodeB = {
id: "b",
type: "genericNode",
position: { x: 100, y: 0 },
data: { id: "b", type: "TextOutput", node: {} },
} as AllNodeType;
const movedNodeA = {
...nodeA,
position: { x: 25, y: 25 },
} as AllNodeType;
const edgeAb = {
id: "e-ab",
source: "a",
target: "b",
} as EdgeType;
function seedFlow(flowId: string, emit = jest.fn()) {
useFlowsManagerStore.setState({
currentFlowId: flowId,
currentFlow: {
id: flowId,
name: "Flow",
description: "",
data: { nodes: [nodeA, nodeB], edges: [edgeAb] },
},
});
useFlowStore.setState({
nodes: [nodeA, nodeB],
edges: [edgeAb],
currentFlow: {
id: flowId,
name: "Flow",
description: "",
data: { nodes: [nodeA, nodeB], edges: [edgeAb] },
},
collaborationOperationMode: true,
isApplyingRemoteOperations: false,
onCollaborationOperations: emit,
});
return emit;
}
describe("collaboration edge-case store wiring", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("cut emits one combined delete batch", () => {
const emit = seedFlow("cut-flow");
useFlowStore.getState().setLastCopiedSelection(
{
nodes: [nodeA],
edges: [],
},
true,
);
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith([
{ type: "delete_nodes", ids: ["a"] },
{ type: "delete_edges", ids: ["e-ab"] },
]);
});
it("undo emits the resulting graph delta once", () => {
const emit = seedFlow("undo-flow");
useFlowsManagerStore.getState().takeSnapshot();
useFlowStore.setState({
nodes: [movedNodeA, nodeB],
edges: [edgeAb],
});
useFlowsManagerStore.getState().undo();
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith([
{ type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] },
]);
});
it("redo emits the resulting graph delta once", () => {
const emit = seedFlow("redo-flow");
useFlowsManagerStore.getState().takeSnapshot();
useFlowStore.setState({
nodes: [movedNodeA, nodeB],
edges: [edgeAb],
});
useFlowsManagerStore.getState().undo();
emit.mockClear();
useFlowsManagerStore.getState().redo();
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith([
{ type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] },
]);
});
it("remote operations clear stale undo history so undo does not resurrect remote deletes", () => {
const emit = seedFlow("remote-delete-flow");
useFlowsManagerStore.getState().takeSnapshot();
applyRemoteFlowOperations([{ type: "delete_nodes", ids: ["a"] }]);
emit.mockClear();
useFlowsManagerStore.getState().undo();
expect(useFlowStore.getState().nodes.map((node) => node.id)).toEqual(["b"]);
expect(useFlowStore.getState().edges).toEqual([]);
expect(emit).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,145 @@
import useFlowStore from "@/stores/flowStore";
import type { AllNodeType, EdgeType } from "@/types/flow";
import {
applyFlowOperationsLocally,
applyRemoteFlowOperations,
buildGraphDiffOperations,
buildUpdateMetadataOperation,
} from "../flow-operation-adapter";
const nodeA = {
id: "a",
type: "genericNode",
position: { x: 0, y: 0 },
data: { id: "a", type: "TextInput", node: {} },
} as AllNodeType;
const nodeB = {
id: "b",
type: "genericNode",
position: { x: 100, y: 0 },
data: { id: "b", type: "TextOutput", node: {} },
} as AllNodeType;
const edgeAb = {
id: "e-ab",
source: "a",
target: "b",
} as EdgeType;
describe("flow-operation-adapter", () => {
beforeEach(() => {
useFlowStore.setState({
nodes: [nodeA, nodeB],
edges: [edgeAb],
currentFlow: {
id: "flow-1",
name: "Flow",
description: "",
data: { nodes: [nodeA, nodeB], edges: [edgeAb] },
},
collaborationOperationMode: false,
isApplyingRemoteOperations: false,
onCollaborationOperations: undefined,
});
});
it("buildGraphDiffOperations detects node updates and edge deletes", () => {
const updatedA = {
...nodeA,
position: { x: 10, y: 10 },
} as AllNodeType;
const operations = buildGraphDiffOperations(
[nodeA, nodeB],
[edgeAb],
[updatedA, nodeB],
[],
);
expect(operations).toEqual(
expect.arrayContaining([
{ type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] },
{ type: "delete_edges", ids: ["e-ab"] },
]),
);
});
it("buildGraphDiffOperations represents edge reconnect as delete plus add", () => {
const reconnectedEdge = {
...edgeAb,
id: "e-reconnected",
source: "b",
target: "a",
} as EdgeType;
const operations = buildGraphDiffOperations(
[nodeA, nodeB],
[edgeAb],
[nodeA, nodeB],
[reconnectedEdge],
);
expect(operations).toEqual([
{ type: "delete_edges", ids: ["e-ab"] },
{ type: "add_edges", edges: [reconnectedEdge] },
]);
});
it("buildUpdateMetadataOperation ignores graph collections and viewport", () => {
const operation = buildUpdateMetadataOperation(
{
nodes: [nodeA],
edges: [edgeAb],
viewport: { x: 0, y: 0, zoom: 1 },
theme: "old",
removed: true,
},
{
nodes: [],
edges: [],
viewport: { x: 10, y: 10, zoom: 2 },
theme: "new",
},
);
expect(operation).toEqual({
type: "update_metadata",
fields: { theme: "new" },
delete_keys: ["removed"],
});
});
it("applyFlowOperationsLocally applies delete_nodes with incident edges", () => {
const result = applyFlowOperationsLocally(
[nodeA, nodeB],
[edgeAb],
[{ type: "delete_nodes", ids: ["a"] }],
);
expect(result.nodes.map((node) => node.id)).toEqual(["b"]);
expect(result.edges).toEqual([]);
});
it("applyRemoteFlowOperations updates the store without emitting collaboration ops", () => {
const emit = jest.fn();
useFlowStore.setState({
onCollaborationOperations: emit,
collaborationOperationMode: true,
});
applyRemoteFlowOperations([
{
type: "update_nodes",
nodes: [{ ...nodeA, position: { x: 25, y: 25 } } as AllNodeType],
},
]);
expect(useFlowStore.getState().nodes[0]?.position).toEqual({
x: 25,
y: 25,
});
expect(emit).not.toHaveBeenCalled();
});
});

View File

@ -1,5 +1,7 @@
import { renderHook } from "@testing-library/react";
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { FlowType } from "@/types/flow";
import { useDebounce } from "../../use-debounce";
import useAutoSaveFlow from "../use-autosave-flow";
import useSaveFlow from "../use-save-flow";
@ -8,6 +10,7 @@ import useSaveFlow from "../use-save-flow";
jest.mock("../use-save-flow");
jest.mock("../../use-debounce");
jest.mock("@/stores/flowsManagerStore");
jest.mock("@/stores/flowStore");
describe("useAutoSaveFlow", () => {
const mockSaveFlow = jest.fn();
@ -21,6 +24,11 @@ describe("useAutoSaveFlow", () => {
mockDebouncedFn.mockImplementation(fn);
return mockDebouncedFn;
});
(useFlowStore as unknown as { getState: jest.Mock }).getState = jest.fn(
() => ({
collaborationOperationMode: false,
}),
);
});
it("should return a debounced autosave function", () => {
@ -54,12 +62,35 @@ describe("useAutoSaveFlow", () => {
const { result } = renderHook(() => useAutoSaveFlow());
const autoSaveFlow = result.current;
const mockFlow = { id: "flow-1", name: "Test Flow" } as any;
const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType;
autoSaveFlow(mockFlow);
expect(mockSaveFlow).toHaveBeenCalledWith(mockFlow);
});
it("should not call saveFlow when collaboration operation mode is enabled", () => {
(useFlowsManagerStore as unknown as jest.Mock).mockImplementation(
(selector) => {
const state = {
autoSaving: true,
autoSavingInterval: 3000,
};
return selector(state);
},
);
(useFlowStore as unknown as jest.Mock).mockImplementation(() => undefined);
(useFlowStore as unknown as { getState: jest.Mock }).getState = jest.fn(
() => ({
collaborationOperationMode: true,
}),
);
const { result } = renderHook(() => useAutoSaveFlow());
result.current({ id: "flow-1" } as never);
expect(mockSaveFlow).not.toHaveBeenCalled();
});
it("should not call saveFlow when autoSaving is disabled", () => {
(useFlowsManagerStore as unknown as jest.Mock).mockImplementation(
(selector) => {
@ -74,7 +105,7 @@ describe("useAutoSaveFlow", () => {
const { result } = renderHook(() => useAutoSaveFlow());
const autoSaveFlow = result.current;
const mockFlow = { id: "flow-1", name: "Test Flow" } as any;
const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType;
autoSaveFlow(mockFlow);
expect(mockSaveFlow).not.toHaveBeenCalled();
@ -158,7 +189,7 @@ describe("useAutoSaveFlow", () => {
);
const { result, rerender } = renderHook(() => useAutoSaveFlow());
const mockFlow = { id: "flow-1", name: "Test Flow" } as any;
const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType;
// AutoSaving enabled
result.current(mockFlow);

View File

@ -0,0 +1,168 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import useFlowStore from "@/stores/flowStore";
import {
readCollaborationOperationBetaEnabled,
writeCollaborationOperationBetaEnabled,
} from "../collaboration-operation-beta";
import { useFlowCollaborationEditing } from "../use-flow-collaboration-editing";
const mockSubmitOperations = jest.fn().mockResolvedValue({
type: "operation.accepted",
revision: 1,
});
const mockDisconnect = jest.fn();
const mockGetFlow = jest.fn();
const mockApplyFlowToCanvas = jest.fn();
const mockClearUndoRedoHistory = jest.fn();
jest.mock("../use-flow-collaboration", () => ({
useFlowCollaboration: jest.fn(() => ({
status: "ready",
connectionId: "conn-1",
currentRevision: 0,
users: [],
isReady: true,
submitOperations: mockSubmitOperations,
disconnect: mockDisconnect,
})),
}));
jest.mock("@/controllers/API/queries/flows/use-get-flow", () => ({
useGetFlow: () => ({ mutateAsync: mockGetFlow }),
}));
jest.mock("@/hooks/flows/use-apply-flow-to-canvas", () => ({
__esModule: true,
default: () => mockApplyFlowToCanvas,
}));
jest.mock("@/stores/flowsManagerStore", () => {
const state = {
currentFlowId: "flow-1",
flows: [],
setFlows: jest.fn(),
setCurrentFlow: jest.fn(),
clearUndoRedoHistory: mockClearUndoRedoHistory,
};
const useFlowsManagerStore = (selector?: (value: typeof state) => unknown) =>
selector ? selector(state) : state;
useFlowsManagerStore.getState = () => state;
return { __esModule: true, default: useFlowsManagerStore };
});
describe("useFlowCollaborationEditing", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSubmitOperations.mockResolvedValue({
type: "operation.accepted",
revision: 1,
});
writeCollaborationOperationBetaEnabled(false);
useFlowStore.setState({
collaborationOperationMode: false,
onCollaborationOperations: undefined,
flushCollaborationSave: undefined,
nodes: [],
edges: [],
currentFlow: {
id: "flow-1",
name: "Flow",
description: "",
data: { nodes: [], edges: [] },
},
});
mockGetFlow.mockResolvedValue({
id: "flow-1",
name: "Flow",
description: "",
data: { nodes: [], edges: [] },
});
});
it("does not enable collaboration mode when the beta toggle is off", async () => {
const { useFlowCollaboration } = jest.requireMock(
"../use-flow-collaboration",
);
renderHook(() =>
useFlowCollaborationEditing({
flowId: "flow-1",
}),
);
await act(async () => {});
expect(useFlowCollaboration).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
expect(useFlowStore.getState().collaborationOperationMode).toBe(false);
});
it("enables collaboration mode and reloads when the beta toggle is turned on", async () => {
const { result } = renderHook(() =>
useFlowCollaborationEditing({
flowId: "flow-1",
}),
);
await act(async () => {
await result.current.setBetaEnabled(true);
});
expect(readCollaborationOperationBetaEnabled()).toBe(true);
expect(mockGetFlow).toHaveBeenCalledWith({ id: "flow-1" });
expect(mockApplyFlowToCanvas).toHaveBeenCalled();
expect(useFlowStore.getState().collaborationOperationMode).toBe(true);
});
it("submits operations emitted from the flow store when collaboration mode is active", async () => {
writeCollaborationOperationBetaEnabled(true);
renderHook(() =>
useFlowCollaborationEditing({
flowId: "flow-1",
}),
);
await act(async () => {});
await act(async () => {
useFlowStore
.getState()
.onCollaborationOperations?.([{ type: "delete_edges", ids: ["e1"] }]);
});
await waitFor(() => {
expect(mockSubmitOperations).toHaveBeenCalledWith([
{ type: "delete_edges", ids: ["e1"] },
]);
});
});
it("reloads and drops pending operations when submit fails", async () => {
mockSubmitOperations.mockRejectedValueOnce(
new Error("Stale base revision"),
);
writeCollaborationOperationBetaEnabled(true);
renderHook(() =>
useFlowCollaborationEditing({
flowId: "flow-1",
}),
);
await act(async () => {});
await act(async () => {
useFlowStore
.getState()
.onCollaborationOperations?.([{ type: "delete_edges", ids: ["e1"] }]);
});
await waitFor(() => {
expect(mockGetFlow).toHaveBeenCalledWith({ id: "flow-1" });
});
});
});

View File

@ -154,6 +154,12 @@ describe("useFlowCollaboration", () => {
expect(instances).toHaveLength(0);
});
it("should not open a socket when enabled is false", async () => {
await mountHook({ flowId: "flow-1", enabled: false });
expect(instances).toHaveLength(0);
});
it("should open the socket, send session.start, and become ready on session.ready", async () => {
const { result } = await mountHook({ flowId: "flow-1" });

View File

@ -1,4 +1,6 @@
import { renderHook } from "@testing-library/react";
import type { FlowType } from "@/types/flow";
import type { FlowOperation } from "@/types/flow-operations";
import useSaveFlow from "../use-save-flow";
const mockSetFlows = jest.fn();
@ -8,8 +10,31 @@ const mockSetCurrentFlow = jest.fn();
const mockGetFlow = jest.fn();
const mockMutate = jest.fn();
let flowStoreState: any;
let flowsManagerState: any;
type MockFlowStoreState = {
collaborationOperationMode: boolean;
flushCollaborationSave?: () => Promise<void>;
onCollaborationOperations?: (operations: FlowOperation[]) => void;
currentFlow: FlowType | null;
nodes: FlowType["data"]["nodes"];
edges: FlowType["data"]["edges"];
reactFlowInstance: {
getViewport: jest.Mock;
} | null;
onFlowPage: boolean;
setCurrentFlow: typeof mockSetCurrentFlow;
};
type MockFlowsManagerState = {
currentFlow: FlowType | null;
flows: FlowType[];
setFlows: typeof mockSetFlows;
setSaveLoading: typeof mockSetSaveLoading;
};
type StoreSelector<T> = (state: T) => unknown;
let flowStoreState: MockFlowStoreState;
let flowsManagerState: MockFlowsManagerState;
jest.mock("@/controllers/API/queries/flows/use-get-flow", () => ({
useGetFlow: () => ({ mutate: mockGetFlow }),
@ -21,14 +46,16 @@ jest.mock("@/controllers/API/queries/flows/use-patch-update-flow", () => ({
jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: (selector: any) =>
default: (
selector: StoreSelector<{ setErrorData: typeof mockSetErrorData }>,
) =>
selector({
setErrorData: mockSetErrorData,
}),
}));
jest.mock("@/stores/flowStore", () => {
const useFlowStore = (selector: any) =>
const useFlowStore = (selector?: StoreSelector<MockFlowStoreState>) =>
selector ? selector(flowStoreState) : flowStoreState;
useFlowStore.getState = () => flowStoreState;
@ -39,8 +66,9 @@ jest.mock("@/stores/flowStore", () => {
});
jest.mock("@/stores/flowsManagerStore", () => {
const useFlowsManagerStore = (selector: any) =>
selector ? selector(flowsManagerState) : flowsManagerState;
const useFlowsManagerStore = (
selector?: StoreSelector<MockFlowsManagerState>,
) => (selector ? selector(flowsManagerState) : flowsManagerState);
useFlowsManagerStore.getState = () => flowsManagerState;
return {
@ -65,9 +93,11 @@ describe("useSaveFlow", () => {
folder_id: "folder-1",
endpoint_name: "saved-flow",
locked: false,
};
} as FlowType;
flowStoreState = {
collaborationOperationMode: false,
flushCollaborationSave: undefined,
currentFlow: {
...savedFlow,
data: {
@ -104,6 +134,68 @@ describe("useSaveFlow", () => {
});
});
it("flushes collaboration operations instead of PATCH when collaboration mode is active", async () => {
const flushCollaborationSave = jest.fn().mockResolvedValue(undefined);
flowStoreState = {
...flowStoreState,
collaborationOperationMode: true,
flushCollaborationSave,
};
const { result } = renderHook(() => useSaveFlow());
await expect(result.current()).resolves.toBeUndefined();
expect(flushCollaborationSave).toHaveBeenCalledTimes(1);
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetSaveLoading).toHaveBeenCalledWith(true);
expect(mockSetSaveLoading).toHaveBeenCalledWith(false);
});
it("emits flow.data metadata updates before flushing collaboration save", async () => {
const flushCollaborationSave = jest.fn().mockResolvedValue(undefined);
const onCollaborationOperations = jest.fn();
flowsManagerState.currentFlow = {
...flowsManagerState.currentFlow,
data: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
theme: "old",
stale_key: true,
},
} as FlowType;
flowStoreState = {
...flowStoreState,
collaborationOperationMode: true,
flushCollaborationSave,
onCollaborationOperations,
currentFlow: {
...flowStoreState.currentFlow,
data: {
nodes: [],
edges: [],
viewport: { x: 10, y: 10, zoom: 2 },
theme: "new",
},
} as FlowType,
};
const { result } = renderHook(() => useSaveFlow());
await expect(result.current()).resolves.toBeUndefined();
expect(onCollaborationOperations).toHaveBeenCalledWith([
{
type: "update_metadata",
fields: { theme: "new" },
delete_keys: ["stale_key"],
},
]);
expect(flushCollaborationSave).toHaveBeenCalledTimes(1);
expect(mockMutate).not.toHaveBeenCalled();
});
it("persists empty-node flows instead of leaving the save promise pending", async () => {
const { result } = renderHook(() => useSaveFlow());
@ -142,7 +234,7 @@ describe("useSaveFlow", () => {
endpoint_name: "saved-flow",
locked: false,
is_component: false,
};
} as FlowType;
flowStoreState = {
currentFlow: null,
@ -151,6 +243,7 @@ describe("useSaveFlow", () => {
reactFlowInstance: null,
onFlowPage: false,
setCurrentFlow: mockSetCurrentFlow,
collaborationOperationMode: false,
};
flowsManagerState = {

View File

@ -0,0 +1,62 @@
import { renderHook } from "@testing-library/react";
import useUploadFlow from "../use-upload-flow";
const mockGetObjectsFromFilelist = jest.fn();
const mockProcessDataFromFlow = jest.fn();
const mockAddFlow = jest.fn();
const mockPaste = jest.fn();
jest.mock("@/helpers/get-objects-from-filelist", () => ({
getObjectsFromFilelist: (...args: unknown[]) =>
mockGetObjectsFromFilelist(...args),
}));
jest.mock("@/utils/reactflowUtils", () => ({
processDataFromFlow: (...args: unknown[]) => mockProcessDataFromFlow(...args),
}));
jest.mock("../use-add-flow", () => ({
__esModule: true,
default: () => mockAddFlow,
}));
jest.mock("@/stores/flowStore", () => {
const useFlowStoreMock = (
selector: (state: { paste: jest.Mock }) => unknown,
) => selector({ paste: mockPaste });
return {
__esModule: true,
default: useFlowStoreMock,
};
});
describe("useUploadFlow", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("uses paste for canvas JSON drop/import so collaboration mode can emit add operations", async () => {
const flow = {
id: "flow-1",
name: "Imported Flow",
data: {
nodes: [{ id: "n1" }],
edges: [{ id: "e1", source: "n1", target: "n2" }],
},
};
mockGetObjectsFromFilelist.mockResolvedValue([flow]);
mockProcessDataFromFlow.mockResolvedValue(undefined);
const { result } = renderHook(() => useUploadFlow());
const position = { x: 10, y: 20 };
await result.current({
files: [new File(["{}"], "flow.json", { type: "application/json" })],
position,
});
expect(mockProcessDataFromFlow).toHaveBeenCalledWith(flow);
expect(mockPaste).toHaveBeenCalledWith(flow.data, position);
expect(mockAddFlow).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,22 @@
const COLLABORATION_OPERATION_BETA_STORAGE_KEY =
"langflow_collaboration_operation_beta";
export function readCollaborationOperationBetaEnabled(): boolean {
if (typeof window === "undefined") {
return false;
}
return (
window.localStorage.getItem(COLLABORATION_OPERATION_BETA_STORAGE_KEY) ===
"true"
);
}
export function writeCollaborationOperationBetaEnabled(enabled: boolean): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(
COLLABORATION_OPERATION_BETA_STORAGE_KEY,
enabled ? "true" : "false",
);
}

View File

@ -0,0 +1,199 @@
import { cloneDeep } from "lodash";
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { AllNodeType, EdgeType, FlowType } from "@/types/flow";
import type { FlowOperation } from "@/types/flow-operations";
import { cleanEdges } from "@/utils/reactflowUtils";
import { getInputsAndOutputs } from "@/utils/storeUtils";
import { coalesceDeleteIds } from "./flow-operation-diff";
export type { FlowMutationOptions } from "@/types/flow-operations";
export {
buildGraphDiffOperations,
buildUpdateMetadataOperation,
buildUpdateNodesOperation,
} from "./flow-operation-diff";
function applyDeleteNodes(
nodes: AllNodeType[],
edges: EdgeType[],
ids: string[],
): { nodes: AllNodeType[]; edges: EdgeType[] } {
const nodeIds = new Set(coalesceDeleteIds(ids));
const nextNodes = nodes.filter((node) => !nodeIds.has(node.id));
const nextEdges = edges.filter(
(edge) => !nodeIds.has(edge.source) && !nodeIds.has(edge.target),
);
return { nodes: nextNodes, edges: nextEdges };
}
function applyDeleteEdges(edges: EdgeType[], ids: string[]): EdgeType[] {
const edgeIds = new Set(coalesceDeleteIds(ids));
return edges.filter((edge) => !edgeIds.has(edge.id));
}
export function applyFlowOperationsLocally(
nodes: AllNodeType[],
edges: EdgeType[],
operations: FlowOperation[],
): { nodes: AllNodeType[]; edges: EdgeType[] } {
let nextNodes = cloneDeep(nodes);
let nextEdges = cloneDeep(edges);
for (const operation of operations) {
switch (operation.type) {
case "add_nodes": {
const existingIds = new Set(nextNodes.map((node) => node.id));
for (const node of operation.nodes) {
if (!existingIds.has(node.id)) {
nextNodes.push(cloneDeep(node));
existingIds.add(node.id);
}
}
break;
}
case "update_nodes": {
const nodeMap = new Map(nextNodes.map((node) => [node.id, node]));
for (const node of operation.nodes) {
if (nodeMap.has(node.id)) {
nodeMap.set(node.id, cloneDeep(node));
}
}
nextNodes = Array.from(nodeMap.values());
break;
}
case "delete_nodes": {
const deleted = applyDeleteNodes(nextNodes, nextEdges, operation.ids);
nextNodes = deleted.nodes;
nextEdges = deleted.edges;
break;
}
case "add_edges": {
const existingIds = new Set(nextEdges.map((edge) => edge.id));
for (const edge of operation.edges) {
if (!existingIds.has(edge.id)) {
nextEdges.push(cloneDeep(edge));
existingIds.add(edge.id);
}
}
break;
}
case "delete_edges": {
nextEdges = applyDeleteEdges(nextEdges, operation.ids);
break;
}
case "update_metadata":
break;
default:
break;
}
}
const cleaned = cleanEdges(nextNodes, nextEdges);
return { nodes: nextNodes, edges: cleaned.edges };
}
export function applyRemoteFlowOperations(operations: FlowOperation[]): void {
const store = useFlowStore.getState();
const metadataOps = operations.filter(
(operation) => operation.type === "update_metadata",
);
const graphOps = operations.filter(
(operation) => operation.type !== "update_metadata",
);
const { nodes, edges } = applyFlowOperationsLocally(
store.nodes,
store.edges,
graphOps,
);
const { inputs, outputs } = getInputsAndOutputs(nodes);
useFlowStore.setState({ isApplyingRemoteOperations: true });
try {
useFlowStore.setState({
nodes,
edges,
flowState: undefined,
inputs,
outputs,
hasIO: inputs.length > 0 || outputs.length > 0,
});
useFlowStore.getState().updateCurrentFlow({ nodes, edges });
const currentFlow = useFlowStore.getState().currentFlow;
if (metadataOps.length > 0 && currentFlow?.data) {
const nextData = { ...currentFlow.data };
for (const operation of metadataOps) {
if (operation.type !== "update_metadata") {
continue;
}
for (const [key, value] of Object.entries(operation.fields)) {
if (key === "nodes" || key === "edges") {
continue;
}
nextData[key] = value;
}
for (const key of operation.delete_keys ?? []) {
if (key === "nodes" || key === "edges") {
continue;
}
delete nextData[key];
}
}
useFlowStore.getState().setCurrentFlow({
...currentFlow,
data: {
...nextData,
nodes,
edges,
},
});
}
} finally {
useFlowStore.setState({ isApplyingRemoteOperations: false });
}
useFlowsManagerStore.getState().clearUndoRedoHistory?.();
}
export function syncSavedFlowStateFromCanvas(): void {
const flowStore = useFlowStore.getState();
const currentFlow = flowStore.currentFlow;
if (!currentFlow) {
return;
}
const viewport = flowStore.reactFlowInstance?.getViewport() ??
currentFlow.data?.viewport ?? {
x: 0,
y: 0,
zoom: 1,
};
const updatedFlow: FlowType = {
...currentFlow,
data: {
...currentFlow.data,
nodes: flowStore.nodes,
edges: flowStore.edges,
viewport,
},
};
flowStore.setCurrentFlow(updatedFlow);
const flows = useFlowsManagerStore.getState().flows;
if (!flows) {
return;
}
useFlowsManagerStore
.getState()
.setFlows(
flows.map((flow) => (flow.id === updatedFlow.id ? updatedFlow : flow)),
);
if (flowStore.onFlowPage) {
useFlowsManagerStore.getState().setCurrentFlow(updatedFlow);
}
}

View File

@ -0,0 +1,161 @@
import { cloneDeep, isEqual } from "lodash";
import type { AllNodeType, EdgeType } from "@/types/flow";
import type { FlowOperation, UpdateMetadataOp } from "@/types/flow-operations";
export function coalesceDeleteIds(ids: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!seen.has(id)) {
seen.add(id);
result.push(id);
}
}
return result;
}
function nodeSnapshot(node: AllNodeType): AllNodeType {
const { selected: _selected, ...rest } = node;
return rest as AllNodeType;
}
function edgeSnapshot(edge: EdgeType): EdgeType {
const { selected: _selected, ...rest } = edge;
return rest as EdgeType;
}
function nodesEqual(a: AllNodeType, b: AllNodeType): boolean {
return isEqual(nodeSnapshot(a), nodeSnapshot(b));
}
function edgesEqual(a: EdgeType, b: EdgeType): boolean {
return isEqual(edgeSnapshot(a), edgeSnapshot(b));
}
export function buildGraphDiffOperations(
prevNodes: AllNodeType[],
prevEdges: EdgeType[],
nextNodes: AllNodeType[],
nextEdges: EdgeType[],
): FlowOperation[] {
const operations: FlowOperation[] = [];
const prevNodeMap = new Map(prevNodes.map((node) => [node.id, node]));
const nextNodeMap = new Map(nextNodes.map((node) => [node.id, node]));
const addedNodes: AllNodeType[] = [];
const updatedNodes: AllNodeType[] = [];
const deletedNodeIds: string[] = [];
for (const node of nextNodes) {
const previous = prevNodeMap.get(node.id);
if (!previous) {
addedNodes.push(cloneDeep(node));
continue;
}
if (!nodesEqual(previous, node)) {
updatedNodes.push(cloneDeep(node));
}
}
for (const node of prevNodes) {
if (!nextNodeMap.has(node.id)) {
deletedNodeIds.push(node.id);
}
}
if (addedNodes.length > 0) {
operations.push({ type: "add_nodes", nodes: addedNodes });
}
if (updatedNodes.length > 0) {
operations.push({ type: "update_nodes", nodes: updatedNodes });
}
if (deletedNodeIds.length > 0) {
operations.push({
type: "delete_nodes",
ids: coalesceDeleteIds(deletedNodeIds),
});
}
const prevEdgeMap = new Map(prevEdges.map((edge) => [edge.id, edge]));
const nextEdgeMap = new Map(nextEdges.map((edge) => [edge.id, edge]));
const addedEdges: EdgeType[] = [];
const deletedEdgeIds: string[] = [];
for (const edge of nextEdges) {
const previous = prevEdgeMap.get(edge.id);
if (!previous) {
addedEdges.push(cloneDeep(edge));
continue;
}
if (!edgesEqual(previous, edge)) {
deletedEdgeIds.push(edge.id);
addedEdges.push(cloneDeep(edge));
}
}
for (const edge of prevEdges) {
if (!nextEdgeMap.has(edge.id)) {
deletedEdgeIds.push(edge.id);
}
}
if (deletedEdgeIds.length > 0) {
operations.push({
type: "delete_edges",
ids: coalesceDeleteIds(deletedEdgeIds),
});
}
if (addedEdges.length > 0) {
operations.push({ type: "add_edges", edges: addedEdges });
}
return operations;
}
export function buildUpdateNodesOperation(nodes: AllNodeType[]): FlowOperation {
return {
type: "update_nodes",
nodes: nodes.map((node) => cloneDeep(node)),
};
}
export function buildUpdateMetadataOperation(
previousData: Record<string, unknown> | null | undefined,
nextData: Record<string, unknown> | null | undefined,
): UpdateMetadataOp | null {
const previous = previousData ?? {};
const next = nextData ?? {};
const fields: Record<string, unknown> = {};
const deleteKeys: string[] = [];
const ignoredKeys = new Set(["nodes", "edges", "viewport"]);
for (const [key, value] of Object.entries(next)) {
if (ignoredKeys.has(key)) {
continue;
}
if (!isEqual(previous[key], value)) {
fields[key] = cloneDeep(value);
}
}
for (const key of Object.keys(previous)) {
if (ignoredKeys.has(key)) {
continue;
}
if (!(key in next)) {
deleteKeys.push(key);
}
}
if (Object.keys(fields).length === 0 && deleteKeys.length === 0) {
return null;
}
return {
type: "update_metadata",
fields,
delete_keys: coalesceDeleteIds(deleteKeys),
};
}

View File

@ -1,3 +1,4 @@
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { FlowType } from "@/types/flow";
import { useDebounce } from "../use-debounce";
@ -11,7 +12,7 @@ const useAutoSaveFlow = () => {
);
const autoSaveFlow = useDebounce((flow?: FlowType) => {
if (autoSaving) {
if (autoSaving && !useFlowStore.getState().collaborationOperationMode) {
saveFlow(flow);
}
}, autoSavingInterval);

View File

@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow";
import {
readCollaborationOperationBetaEnabled,
writeCollaborationOperationBetaEnabled,
} from "@/hooks/flows/collaboration-operation-beta";
import {
applyRemoteFlowOperations,
syncSavedFlowStateFromCanvas,
} from "@/hooks/flows/flow-operation-adapter";
import useApplyFlowToCanvas from "@/hooks/flows/use-apply-flow-to-canvas";
import { useFlowCollaboration } from "@/hooks/flows/use-flow-collaboration";
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import type { CollaborationPresenceUser } from "@/types/flow-collaboration";
import type { FlowOperation } from "@/types/flow-operations";
type UseFlowCollaborationEditingOptions = {
flowId: string | undefined;
};
export type UseFlowCollaborationEditingReturn = {
betaEnabled: boolean;
setBetaEnabled: (enabled: boolean) => Promise<void>;
users: CollaborationPresenceUser[];
isCollaborationReady: boolean;
collaborationStatus: ReturnType<typeof useFlowCollaboration>["status"];
};
export function useFlowCollaborationEditing({
flowId,
}: UseFlowCollaborationEditingOptions): UseFlowCollaborationEditingReturn {
const [betaEnabled, setBetaEnabledState] = useState(
readCollaborationOperationBetaEnabled,
);
const applyFlowToCanvas = useApplyFlowToCanvas();
const { mutateAsync: getFlow } = useGetFlow();
const submitChainRef = useRef<Promise<void>>(Promise.resolve());
const queuedOperationsRef = useRef<FlowOperation[]>([]);
const collaborationReadyRef = useRef(false);
const reloadingRef = useRef(false);
const reloadFlowFromServer = useCallback(async () => {
if (!flowId) {
return;
}
queuedOperationsRef.current = [];
submitChainRef.current = Promise.resolve();
reloadingRef.current = true;
useFlowStore.setState({ isApplyingRemoteOperations: true });
try {
const response = await getFlow({ id: flowId });
if (useFlowsManagerStore.getState().currentFlowId !== flowId) {
return;
}
applyFlowToCanvas(response);
syncSavedFlowStateFromCanvas();
useFlowsManagerStore.getState().clearUndoRedoHistory?.(flowId);
} finally {
reloadingRef.current = false;
useFlowStore.setState({ isApplyingRemoteOperations: false });
}
}, [applyFlowToCanvas, flowId, getFlow]);
const submitOperationsRef = useRef<
((operations: FlowOperation[]) => Promise<unknown>) | null
>(null);
const drainQueue = useCallback(async () => {
if (
!collaborationReadyRef.current ||
reloadingRef.current ||
queuedOperationsRef.current.length === 0
) {
return;
}
const submit = submitOperationsRef.current;
if (!submit) {
return;
}
const batch = queuedOperationsRef.current.splice(
0,
queuedOperationsRef.current.length,
);
try {
await submit(batch);
} catch {
queuedOperationsRef.current = [];
await reloadFlowFromServer();
}
}, [reloadFlowFromServer]);
const enqueueOperations = useCallback(
(operations: FlowOperation[]) => {
if (!operations.length || reloadingRef.current) {
return;
}
queuedOperationsRef.current.push(...operations);
if (!collaborationReadyRef.current) {
return;
}
submitChainRef.current = submitChainRef.current
.catch(() => {})
.then(drainQueue);
},
[drainQueue],
);
const flushCollaborationSave = useCallback(async () => {
await submitChainRef.current;
if (queuedOperationsRef.current.length > 0) {
if (!collaborationReadyRef.current) {
throw new Error("Collaboration session is not ready");
}
await drainQueue();
}
syncSavedFlowStateFromCanvas();
}, [drainQueue]);
const collaboration = useFlowCollaboration({
flowId,
enabled: betaEnabled && Boolean(flowId),
onRemoteOperation: (message) => {
applyRemoteFlowOperations(message.forward_ops);
},
onReloadRequired: () => {
queuedOperationsRef.current = [];
submitChainRef.current = Promise.resolve();
void reloadFlowFromServer();
},
});
submitOperationsRef.current = collaboration.submitOperations;
collaborationReadyRef.current = collaboration.isReady;
useEffect(() => {
if (!collaboration.isReady || queuedOperationsRef.current.length === 0) {
return;
}
submitChainRef.current = submitChainRef.current
.catch(() => {})
.then(drainQueue);
}, [collaboration.isReady, drainQueue]);
useEffect(() => {
useFlowStore.setState({
collaborationOperationMode: betaEnabled,
onCollaborationOperations: betaEnabled ? enqueueOperations : undefined,
flushCollaborationSave: betaEnabled ? flushCollaborationSave : undefined,
});
return () => {
useFlowStore.setState({
collaborationOperationMode: false,
onCollaborationOperations: undefined,
flushCollaborationSave: undefined,
});
};
}, [betaEnabled, enqueueOperations, flushCollaborationSave]);
const setBetaEnabled = useCallback(
async (enabled: boolean) => {
writeCollaborationOperationBetaEnabled(enabled);
setBetaEnabledState(enabled);
if (flowId) {
await reloadFlowFromServer();
}
},
[flowId, reloadFlowFromServer],
);
return {
betaEnabled,
setBetaEnabled,
users: collaboration.users,
isCollaborationReady: collaboration.isReady,
collaborationStatus: collaboration.status,
};
}

View File

@ -2,6 +2,8 @@ import type { ReactFlowJsonObject } from "@xyflow/react";
import { useTranslation } from "react-i18next";
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow";
import { usePatchUpdateFlow } from "@/controllers/API/queries/flows/use-patch-update-flow";
import { syncSavedFlowStateFromCanvas } from "@/hooks/flows/flow-operation-adapter";
import { buildUpdateMetadataOperation } from "@/hooks/flows/flow-operation-diff";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
@ -19,8 +21,42 @@ const useSaveFlow = () => {
const { mutate } = usePatchUpdateFlow();
const saveFlow = async (flow?: FlowType): Promise<void> => {
const currentFlow = useFlowStore.getState().currentFlow;
const flowStore = useFlowStore.getState();
const currentFlow = flowStore.currentFlow;
const currentSavedFlow = useFlowsManagerStore.getState().currentFlow;
if (flowStore.collaborationOperationMode) {
setSaveLoading(true);
try {
const flowToSave = flow ?? currentFlow;
const metadataOperation = buildUpdateMetadataOperation(
currentSavedFlow?.data as Record<string, unknown> | null | undefined,
flowToSave?.data as Record<string, unknown> | null | undefined,
);
if (metadataOperation) {
flowStore.onCollaborationOperations?.([metadataOperation]);
}
if (flowStore.flushCollaborationSave) {
await flowStore.flushCollaborationSave();
} else {
syncSavedFlowStateFromCanvas();
}
setSaveLoading(false);
return;
} catch (error) {
setSaveLoading(false);
const detail =
error instanceof Error
? error.message
: "Unknown collaboration save error";
setErrorData({
title: t("errors.failedToSaveFlow"),
list: [detail],
});
throw error;
}
}
if (
customStringify(flow || currentFlow) !== customStringify(currentSavedFlow)
) {

View File

@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/utils/utils";
type CollaborationBetaToggleProps = {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
disabled?: boolean;
className?: string;
};
export default function CollaborationBetaToggle({
enabled,
onEnabledChange,
disabled = false,
className,
}: CollaborationBetaToggleProps): JSX.Element {
const { t } = useTranslation();
return (
<div
className={cn(
"flex max-w-xs items-start gap-2 rounded-md border bg-background px-3 py-2 shadow-sm",
className,
)}
data-testid="collaboration-beta-toggle"
>
<Switch
id="collaboration-operation-beta"
checked={enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
data-testid="collaboration-beta-switch"
/>
<div className="space-y-0.5">
<Label
htmlFor="collaboration-operation-beta"
className="text-xs font-medium leading-none"
>
{t("flow.collaboration.betaToggleLabel", {
defaultValue: "Beta: operation-based collaborative editing",
})}
</Label>
<p className="text-[11px] text-muted-foreground">
{t("flow.collaboration.betaToggleDescription", {
defaultValue:
"Uses realtime operation batches instead of full-flow autosave. Turning this on reloads the flow from the server.",
})}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import CollaborationPresence from "../index";
describe("CollaborationPresence", () => {
it("renders other collaborators and hides the current user", () => {
render(
<CollaborationPresence
currentUserId="user-1"
users={[
{ user_id: "user-1", username: "ana" },
{ user_id: "user-2", username: "bob", profile_image: null },
]}
/>,
);
expect(screen.getByTestId("collaboration-presence")).toBeInTheDocument();
expect(
screen.getByTestId("collaboration-presence-user-user-2"),
).toBeInTheDocument();
expect(
screen.queryByTestId("collaboration-presence-user-user-1"),
).not.toBeInTheDocument();
});
it("renders nothing when only the current user is present", () => {
const { container } = render(
<CollaborationPresence
currentUserId="user-1"
users={[{ user_id: "user-1", username: "ana" }]}
/>,
);
expect(container).toBeEmptyDOMElement();
});
});

View File

@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import { BASE_URL_API } from "@/customization/config-constants";
import type { CollaborationPresenceUser } from "@/types/flow-collaboration";
import { cn } from "@/utils/utils";
type CollaborationPresenceProps = {
users: CollaborationPresenceUser[];
currentUserId?: string | null;
className?: string;
};
function profileImageUrl(profileImage?: string | null): string {
return `${BASE_URL_API}files/profile_pictures/${
profileImage ?? "Space/046-rocket.svg"
}`;
}
export default function CollaborationPresence({
users,
currentUserId,
className,
}: CollaborationPresenceProps): JSX.Element | null {
const { t } = useTranslation();
const collaborators = users.filter((user) => user.user_id !== currentUserId);
if (collaborators.length === 0) {
return null;
}
const visibleUsers = collaborators.slice(0, 5);
const overflowCount = collaborators.length - visibleUsers.length;
return (
<div
className={cn("flex items-center gap-2", className)}
data-testid="collaboration-presence"
title={t("flow.collaboration.activeCollaborators", {
count: collaborators.length,
defaultValue: "{{count}} collaborators editing",
})}
>
<span className="text-xs text-muted-foreground">
{t("flow.collaboration.editing", { defaultValue: "Editing" })}
</span>
<div className="flex -space-x-2">
{visibleUsers.map((user) => (
<img
key={user.user_id}
src={profileImageUrl(user.profile_image)}
alt={user.username}
title={user.username}
className="h-7 w-7 rounded-full border-2 border-background object-cover"
data-testid={`collaboration-presence-user-${user.user_id}`}
/>
))}
{overflowCount > 0 ? (
<span
className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-background bg-muted text-[10px] font-medium text-muted-foreground"
data-testid="collaboration-presence-overflow"
>
+{overflowCount}
</span>
) : null}
</div>
</div>
);
}

View File

@ -32,13 +32,19 @@ import { getURL } from "@/controllers/API/helpers/constants";
import { useGetBuildsQuery } from "@/controllers/API/queries/_builds";
import CustomLoader from "@/customization/components/custom-loader";
import { track } from "@/customization/utils/analytics";
import {
buildGraphDiffOperations,
buildUpdateNodesOperation,
} from "@/hooks/flows/flow-operation-diff";
import useApplyFlowToCanvas from "@/hooks/flows/use-apply-flow-to-canvas";
import useAutoSaveFlow from "@/hooks/flows/use-autosave-flow";
import { useFlowCollaborationEditing } from "@/hooks/flows/use-flow-collaboration-editing";
import { useFlowEvents } from "@/hooks/flows/use-flow-events";
import useUploadFlow from "@/hooks/flows/use-upload-flow";
import { useAddComponent } from "@/hooks/use-add-component";
import InspectionPanel from "@/pages/FlowPage/components/InspectionPanel";
import useAssistantManagerStore from "@/stores/assistantManagerStore";
import useAuthStore from "@/stores/authStore";
import useFlowBuilderWelcomeStore from "@/stores/flowBuilderWelcomeStore";
import { nodeColorsName } from "@/utils/styleUtils";
import { isSupportedNodeTypes } from "@/utils/utils";
@ -66,6 +72,8 @@ import {
validateSelection,
} from "../../../../utils/reactflowUtils";
import { edgeTypes, nodeTypes } from "../../consts";
import CollaborationBetaToggle from "../CollaborationBetaToggle";
import CollaborationPresence from "../CollaborationPresence";
import ConnectionLineComponent from "../ConnectionLineComponent";
import FlowBuildingComponent from "../flowBuildingComponent";
import SelectionMenu from "../SelectionMenuComponent";
@ -152,6 +160,21 @@ export default function Page({
const [lastSelection, setLastSelection] =
useState<OnSelectionChangeParams | null>(null);
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
const userData = useAuthStore((state) => state.userData);
const collaborationOperationMode = useFlowStore(
(state) => state.collaborationOperationMode,
);
const onCollaborationOperations = useFlowStore(
(state) => state.onCollaborationOperations,
);
const {
betaEnabled: collaborationBetaEnabled,
setBetaEnabled: setCollaborationBetaEnabled,
users: collaborationUsers,
} = useFlowCollaborationEditing({
flowId: currentFlowId || undefined,
});
const { isAgentWorking, events, lastSettledAt, clearEvents } = useFlowEvents(
currentFlowId || undefined,
@ -468,8 +491,31 @@ export default function Page({
track("Component Deleted", { componentType: n.data.type });
});
}
deleteNode(lastSelection.nodes.map((node) => node.id));
deleteEdge(lastSelection.edges.map((edge) => edge.id));
if (collaborationOperationMode && onCollaborationOperations) {
const prevNodes = useFlowStore.getState().nodes;
const prevEdges = useFlowStore.getState().edges;
deleteNode(
lastSelection.nodes.map((node) => node.id),
{ skipCollaborationEmit: true },
);
deleteEdge(
lastSelection.edges.map((edge) => edge.id),
{ skipCollaborationEmit: true },
);
const nextState = useFlowStore.getState();
const operations = buildGraphDiffOperations(
prevNodes,
prevEdges,
nextState.nodes,
nextState.edges,
);
if (operations.length > 0) {
onCollaborationOperations(operations);
}
} else {
deleteNode(lastSelection.nodes.map((node) => node.id));
deleteEdge(lastSelection.edges.map((edge) => edge.id));
}
}
}
@ -557,20 +603,28 @@ export default function Page({
const onNodeDragStop: OnNodeDrag = useCallback(
(_, node) => {
// 👇 make moving the canvas undoable
autoSaveFlow();
if (collaborationOperationMode && onCollaborationOperations) {
const movedNodes = nodes.filter(
(canvasNode) => canvasNode.selected || canvasNode.id === node.id,
);
if (movedNodes.length > 0) {
onCollaborationOperations([buildUpdateNodesOperation(movedNodes)]);
}
} else {
autoSaveFlow();
}
updateCurrentFlow({ nodes });
setPositionDictionary({});
setIsDragging(false);
setHelperLines({});
},
[
takeSnapshot,
autoSaveFlow,
collaborationOperationMode,
nodes,
edges,
reactFlowInstance,
onCollaborationOperations,
setPositionDictionary,
updateCurrentFlow,
],
);
@ -863,19 +917,6 @@ export default function Page({
? (nodes.find((n) => n.id === selectedNodeId) as AllNodeType)
: null;
// Determine if InspectionPanel should be visible
const showInspectionPanel = inspectionPanelVisible && !!selectedNode;
// Handler to close the inspection panel by deselecting all nodes
const handleCloseInspectionPanel = useCallback(() => {
setNodes((nds) =>
nds.map((node) => ({
...node,
selected: false,
})),
);
}, [setNodes]);
useEffect(() => {
if (inspectionPanelVisible) {
setSelectionMenuVisible(false);
@ -894,6 +935,23 @@ export default function Page({
isAgentWorking={isAgentWorking}
/>
{!isPreviewActive && <FlowToolbar />}
{!isPreviewActive && (
<CollaborationBetaToggle
enabled={collaborationBetaEnabled}
disabled={effectiveLocked}
className="absolute right-4 top-16 z-10"
onEnabledChange={(enabled) => {
void setCollaborationBetaEnabled(enabled);
}}
/>
)}
{!isPreviewActive && collaborationBetaEnabled ? (
<CollaborationPresence
users={collaborationUsers}
currentUserId={userData?.id}
className="absolute right-4 top-[7.5rem] z-10 rounded-md border bg-background px-2 py-1 shadow-sm"
/>
) : null}
{inspectionPanelVisible && (
<InspectionPanel selectedNode={selectedNode} />
)}

View File

@ -19,6 +19,8 @@ import {
trackDataLoaded,
trackFlowBuild,
} from "@/customization/utils/analytics";
import type { FlowMutationOptions } from "@/hooks/flows/flow-operation-adapter";
import { buildGraphDiffOperations } from "@/hooks/flows/flow-operation-diff";
import { brokenEdgeMessage } from "@/utils/utils";
import { BuildStatus, EventDeliveryType } from "../constants/enums";
import i18n from "../i18n";
@ -31,6 +33,7 @@ import type {
sourceHandleType,
targetHandleType,
} from "../types/flow";
import type { FlowOperation } from "../types/flow-operations";
import type {
ComponentsToUpdateType,
FlowStoreType,
@ -136,6 +139,10 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
}
},
autoSaveFlow: undefined,
collaborationOperationMode: false,
isApplyingRemoteOperations: false,
onCollaborationOperations: undefined,
flushCollaborationSave: undefined,
componentsToUpdate: [],
setComponentsToUpdate: (change) => {
const newChange =
@ -408,7 +415,9 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
edges: applyEdgeChanges(changes, get().edges),
});
},
setNodes: (change) => {
setNodes: (change, options?: FlowMutationOptions) => {
const prevNodes = get().nodes;
const prevEdges = get().edges;
const newChange =
typeof change === "function" ? change(get().nodes) : change;
const { edges: newEdges } = cleanEdges(newChange, get().edges);
@ -423,11 +432,28 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
hasIO: inputs.length > 0 || outputs.length > 0,
});
get().updateCurrentFlow({ nodes: newChange, edges: newEdges });
if (get().autoSaveFlow) {
if (
get().collaborationOperationMode &&
!get().isApplyingRemoteOperations &&
!options?.skipCollaborationEmit
) {
const operations = buildGraphDiffOperations(
prevNodes,
prevEdges,
newChange,
newEdges,
);
if (operations.length > 0) {
get().onCollaborationOperations?.(operations);
}
}
if (get().autoSaveFlow && !get().collaborationOperationMode) {
get().autoSaveFlow!();
}
},
setEdges: (change) => {
setEdges: (change, options?: FlowMutationOptions) => {
const prevNodes = get().nodes;
const prevEdges = get().edges;
const newChange =
typeof change === "function" ? change(get().edges) : change;
set({
@ -435,7 +461,22 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
flowState: undefined,
});
get().updateCurrentFlow({ edges: newChange });
if (get().autoSaveFlow) {
if (
get().collaborationOperationMode &&
!get().isApplyingRemoteOperations &&
!options?.skipCollaborationEmit
) {
const operations = buildGraphDiffOperations(
prevNodes,
prevEdges,
get().nodes,
newChange,
);
if (operations.length > 0) {
get().onCollaborationOperations?.(operations);
}
}
if (get().autoSaveFlow && !get().collaborationOperationMode) {
get().autoSaveFlow!();
}
},
@ -444,11 +485,15 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
change: AllNodeType | ((oldState: AllNodeType) => AllNodeType),
isUserChange: boolean = true,
callback?: () => void,
options?: FlowMutationOptions,
) => {
if (!get().nodes.find((node) => node.id === id)) {
throw new Error("Node not found");
}
const prevNodes = get().nodes;
const prevEdges = get().edges;
const newChange =
typeof change === "function"
? change(get().nodes.find((node) => node.id === id)!)
@ -480,14 +525,29 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
};
});
get().updateCurrentFlow({ nodes: newNodes, edges: newEdges });
if (get().autoSaveFlow) {
if (
get().collaborationOperationMode &&
!get().isApplyingRemoteOperations &&
!options?.skipCollaborationEmit
) {
const operations = buildGraphDiffOperations(
prevNodes,
prevEdges,
newNodes,
newEdges,
);
if (operations.length > 0) {
get().onCollaborationOperations?.(operations);
}
}
if (get().autoSaveFlow && !get().collaborationOperationMode) {
get().autoSaveFlow!();
}
},
getNode: (id: string) => {
return get().nodes.find((node) => node.id === id);
},
deleteNode: (nodeId) => {
deleteNode: (nodeId, options?: FlowMutationOptions) => {
const { filteredNodes, deletedNode } = get().nodes.reduce<{
filteredNodes: AllNodeType[];
deletedNode: AllNodeType | null;
@ -509,7 +569,7 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
{ filteredNodes: [], deletedNode: null },
);
get().setNodes(filteredNodes);
get().setNodes(filteredNodes, options);
// Clear rightClickedNodeId if the deleted node was right-clicked
const rightClickedNodeId = get().rightClickedNodeId;
@ -528,13 +588,14 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
track("Component Deleted", { componentType: deletedNode.data.type });
}
},
deleteEdge: (edgeId) => {
deleteEdge: (edgeId, options?: FlowMutationOptions) => {
get().setEdges(
get().edges.filter((edge) =>
typeof edgeId === "string"
? edge.id !== edgeId
: !edgeId.includes(edge.id),
),
options,
);
track("Component Connection Deleted", { edgeId });
},
@ -577,6 +638,8 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
const idsMap = {};
let newNodes: AllNodeType[] = get().nodes;
let newEdges = get().edges;
const addedNodes: AllNodeType[] = [];
const addedEdges: EdgeType[] = [];
selection.nodes.forEach((node: Node) => {
if (node.position.y < minimumY) {
minimumY = node.position.y;
@ -634,11 +697,13 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
);
// Add the new node to the list of nodes in state
const pastedNode = { ...newNode, selected: true } as AllNodeType;
addedNodes.push(pastedNode);
newNodes = newNodes
.map((node) => ({ ...node, selected: false }))
.concat({ ...newNode, selected: true });
.concat(pastedNode);
});
get().setNodes(newNodes);
get().setNodes(newNodes, { skipCollaborationEmit: true });
selection.edges.forEach((edge: EdgeType) => {
const source = idsMap[edge.source];
@ -667,42 +732,72 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
};
const id = getHandleId(source, sourceHandle, target, targetHandle);
const pastedEdge = {
source,
target,
sourceHandle,
targetHandle,
id,
data: cloneDeep(edge.data),
selected: false,
} as EdgeType;
addedEdges.push(pastedEdge);
newEdges = addEdge(
{
source,
target,
sourceHandle,
targetHandle,
id,
data: cloneDeep(edge.data),
selected: false,
},
pastedEdge,
newEdges.map((edge) => ({ ...edge, selected: false })),
);
});
get().setEdges(newEdges);
get().setEdges(newEdges, { skipCollaborationEmit: true });
if (
get().collaborationOperationMode &&
!get().isApplyingRemoteOperations &&
(addedNodes.length > 0 || addedEdges.length > 0)
) {
const operations: FlowOperation[] = [];
if (addedNodes.length > 0) {
operations.push({ type: "add_nodes", nodes: addedNodes });
}
if (addedEdges.length > 0) {
operations.push({ type: "add_edges", edges: addedEdges });
}
get().onCollaborationOperations?.(operations);
}
},
setLastCopiedSelection: (newSelection, isCrop = false) => {
if (isCrop) {
const prevNodes = get().nodes;
const prevEdges = get().edges;
const nodesIdsSelected = newSelection!.nodes.map((node) => node.id);
const edgesIdsSelected = newSelection!.edges.map((edge) => edge.id);
nodesIdsSelected.forEach((id) => {
get().deleteNode(id);
});
edgesIdsSelected.forEach((id) => {
get().deleteEdge(id);
});
const newNodes = get().nodes.filter(
const newNodes = prevNodes.filter(
(node) => !nodesIdsSelected.includes(node.id),
);
const newEdges = get().edges.filter(
(edge) => !edgesIdsSelected.includes(edge.id),
const newEdges = prevEdges.filter(
(edge) =>
!edgesIdsSelected.includes(edge.id) &&
!nodesIdsSelected.includes(edge.source) &&
!nodesIdsSelected.includes(edge.target),
);
set({ nodes: newNodes, edges: newEdges });
get().setNodes(newNodes, { skipCollaborationEmit: true });
get().setEdges(newEdges, { skipCollaborationEmit: true });
if (
get().collaborationOperationMode &&
!get().isApplyingRemoteOperations
) {
const operations = buildGraphDiffOperations(
prevNodes,
prevEdges,
newNodes,
newEdges,
);
if (operations.length > 0) {
get().onCollaborationOperations?.(operations);
}
}
}
set({ lastCopiedSelection: newSelection });

View File

@ -1,7 +1,8 @@
import { cloneDeep } from "lodash";
import { create } from "zustand";
import { SAVE_DEBOUNCE_TIME } from "@/constants/constants";
import type { FlowType } from "../types/flow";
import { buildGraphDiffOperations } from "@/hooks/flows/flow-operation-diff";
import type { AllNodeType, EdgeType, FlowType } from "../types/flow";
import type {
FlowsManagerStoreType,
UseUndoRedoOptions,
@ -14,8 +15,14 @@ const defaultOptions: UseUndoRedoOptions = {
enableShortcuts: true,
};
const past = {};
const future = {};
const past: Record<
string,
Array<{ nodes: AllNodeType[]; edges: EdgeType[] }>
> = {};
const future: Record<
string,
Array<{ nodes: AllNodeType[]; edges: EdgeType[] }>
> = {};
const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
IOModalOpen: false,
@ -87,6 +94,12 @@ const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
future[currentFlowId] = [];
},
clearUndoRedoHistory: (flowId?: string) => {
const targetFlowId = flowId ?? get().currentFlowId;
if (!targetFlowId) return;
past[targetFlowId] = [];
future[targetFlowId] = [];
},
undo: () => {
const newState = useFlowStore.getState();
const currentFlowId = get().currentFlowId;
@ -102,8 +115,20 @@ const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
edges: newState.edges,
});
newState.setNodes(pastState.nodes);
newState.setEdges(pastState.edges);
newState.setNodes(pastState.nodes, { skipCollaborationEmit: true });
newState.setEdges(pastState.edges, { skipCollaborationEmit: true });
if (newState.collaborationOperationMode) {
const operations = buildGraphDiffOperations(
newState.nodes,
newState.edges,
pastState.nodes,
pastState.edges,
);
if (operations.length > 0) {
newState.onCollaborationOperations?.(operations);
}
}
}
},
redo: () => {
@ -121,8 +146,20 @@ const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
edges: newState.edges,
});
newState.setNodes(futureState.nodes);
newState.setEdges(futureState.edges);
newState.setNodes(futureState.nodes, { skipCollaborationEmit: true });
newState.setEdges(futureState.edges, { skipCollaborationEmit: true });
if (newState.collaborationOperationMode) {
const operations = buildGraphDiffOperations(
newState.nodes,
newState.edges,
futureState.nodes,
futureState.edges,
);
if (operations.length > 0) {
newState.onCollaborationOperations?.(operations);
}
}
}
},
searchFlowsComponents: "",

View File

@ -42,6 +42,10 @@ export type FlowOperation =
| DeleteEdgesOp
| UpdateMetadataOp;
export type FlowMutationOptions = {
skipCollaborationEmit?: boolean;
};
export type FlowOperationSubmitPayload = {
request_id: string;
base_revision: number;

View File

@ -6,7 +6,12 @@ import type {
ReactFlowInstance,
Viewport,
} from "@xyflow/react";
import type { groupedObjType } from "@/types/components";
import type { AllNodeType, EdgeType, FlowType } from "@/types/flow";
import type {
FlowMutationOptions,
FlowOperation,
} from "@/types/flow-operations";
import type { BuildStatus, EventDeliveryType } from "../../../constants/enums";
import type { LogsLogType, VertexBuildTypeAPI } from "../../api";
import type { ChatInputType, ChatOutputType } from "../../chat";
@ -17,8 +22,8 @@ export type FlowPoolObjectType = {
valid: boolean;
messages: Array<ChatOutputType | ChatInputType> | [];
data: {
artifacts: any | ChatOutputType | ChatInputType;
results: any | ChatOutputType | ChatInputType;
artifacts: unknown | ChatOutputType | ChatInputType;
results: unknown | ChatOutputType | ChatInputType;
};
duration?: string;
progress?: number;
@ -33,8 +38,8 @@ export type FlowPoolObjectTypeNew = {
timestamp: string;
valid: boolean;
data: {
outputs?: any | ChatOutputType | ChatInputType;
results: any | ChatOutputType | ChatInputType;
outputs?: unknown | ChatOutputType | ChatInputType;
results: unknown | ChatOutputType | ChatInputType;
};
duration?: string;
progress?: number;
@ -76,6 +81,12 @@ export type FlowStoreType = {
}) => void;
fitViewNode: (nodeId: string) => void;
autoSaveFlow: ((flow?: FlowType) => void) | undefined;
collaborationOperationMode: boolean;
isApplyingRemoteOperations: boolean;
onCollaborationOperations:
| ((operations: FlowOperation[]) => void)
| undefined;
flushCollaborationSave: (() => Promise<void>) | undefined;
componentsToUpdate: ComponentsToUpdateType[];
setComponentsToUpdate: (
update:
@ -142,31 +153,40 @@ export type FlowStoreType = {
onEdgesChange: OnEdgesChange<EdgeType>;
setNodes: (
update: AllNodeType[] | ((oldState: AllNodeType[]) => AllNodeType[]),
options?: FlowMutationOptions,
) => void;
setEdges: (
update: EdgeType[] | ((oldState: EdgeType[]) => EdgeType[]),
options?: FlowMutationOptions,
) => void;
setNode: (
id: string,
update: AllNodeType | ((oldState: AllNodeType) => AllNodeType),
isUserChange?: boolean,
callback?: () => void,
options?: FlowMutationOptions,
) => void;
getNode: (id: string) => AllNodeType | undefined;
deleteNode: (nodeId: string | Array<string>) => void;
deleteEdge: (edgeId: string | Array<string>) => void;
deleteNode: (
nodeId: string | Array<string>,
options?: FlowMutationOptions,
) => void;
deleteEdge: (
edgeId: string | Array<string>,
options?: FlowMutationOptions,
) => void;
paste: (
selection: { nodes: any; edges: any },
selection: { nodes: AllNodeType[]; edges: EdgeType[] },
position: { x: number; y: number; paneX?: number; paneY?: number },
) => void;
lastCopiedSelection: { nodes: any; edges: any } | null;
lastCopiedSelection: { nodes: AllNodeType[]; edges: EdgeType[] } | null;
setLastCopiedSelection: (
newSelection: { nodes: any; edges: any } | null,
newSelection: { nodes: AllNodeType[]; edges: EdgeType[] } | null,
isCrop?: boolean,
) => void;
cleanFlow: () => void;
setFilterEdge: (newState) => void;
getFilterEdge: any[];
setFilterEdge: (newState: groupedObjType[]) => void;
getFilterEdge: groupedObjType[];
setFilterComponent: (newState) => void;
getFilterComponent: string;
rightClickedNodeId: string | null;

View File

@ -15,6 +15,7 @@ export type FlowsManagerStoreType = {
undo: () => void;
redo: () => void;
takeSnapshot: () => void;
clearUndoRedoHistory: (flowId?: string) => void;
examples: Array<FlowType>;
setExamples: (examples: FlowType[]) => void;
setCurrentFlow: (flow?: FlowType) => void;