Skip to content

Commit f441ada

Browse files
Mary Hippclaude
authored andcommitted
feat(ui): implement exposed fields for canvas workflows
Add support for displaying workflow exposed fields in the canvas parameters panel. Uses a shadow slice pattern to maintain complete state isolation between canvas and workflow tabs. Key features: - Shadow nodes slice mirrors workflow nodes structure for canvas workflows - Context providers redirect field component selectors to canvas workflow data - Middleware intercepts field mutations and routes to appropriate slice - Filters out canvas input nodes from exposed fields - Always displays fields in view mode - Each field wrapped with correct node context for proper data access 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent b24c2cb commit f441ada

21 files changed

+975
-37
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { AppStartListening } from 'app/store/store';
2+
import * as canvasWorkflowNodesActions from 'features/controlLayers/store/canvasWorkflowNodesSlice';
3+
import * as nodesActions from 'features/nodes/store/nodesSlice';
4+
5+
/**
6+
* Listens for field value changes on nodes and redirects them to the canvas workflow nodes slice
7+
* if the node belongs to a canvas workflow (exists in canvasWorkflowNodes but not in nodes).
8+
*/
9+
export const addCanvasWorkflowFieldChangedListener = (startListening: AppStartListening) => {
10+
// List of all field mutation actions from nodesSlice
11+
const fieldMutationActions = [
12+
nodesActions.fieldStringValueChanged,
13+
nodesActions.fieldIntegerValueChanged,
14+
nodesActions.fieldFloatValueChanged,
15+
nodesActions.fieldBooleanValueChanged,
16+
nodesActions.fieldModelIdentifierValueChanged,
17+
nodesActions.fieldEnumModelValueChanged,
18+
nodesActions.fieldSchedulerValueChanged,
19+
nodesActions.fieldBoardValueChanged,
20+
nodesActions.fieldImageValueChanged,
21+
nodesActions.fieldColorValueChanged,
22+
nodesActions.fieldImageCollectionValueChanged,
23+
nodesActions.fieldStringCollectionValueChanged,
24+
nodesActions.fieldIntegerCollectionValueChanged,
25+
nodesActions.fieldFloatCollectionValueChanged,
26+
nodesActions.fieldFloatGeneratorValueChanged,
27+
nodesActions.fieldIntegerGeneratorValueChanged,
28+
nodesActions.fieldStringGeneratorValueChanged,
29+
nodesActions.fieldImageGeneratorValueChanged,
30+
nodesActions.fieldValueReset,
31+
];
32+
33+
for (const actionCreator of fieldMutationActions) {
34+
startListening({
35+
actionCreator,
36+
effect: (action: any, { dispatch, getState }: any) => {
37+
const state = getState();
38+
const { nodeId } = action.payload;
39+
40+
// Check if this node exists in canvas workflow nodes
41+
const canvasWorkflowNode = state.canvasWorkflowNodes.nodes.find((n: any) => n.id === nodeId);
42+
const regularNode = state.nodes.present.nodes.find((n: any) => n.id === nodeId);
43+
44+
// If the node exists in canvas workflow but NOT in regular nodes, redirect the action
45+
if (canvasWorkflowNode && !regularNode) {
46+
// Get the corresponding action from canvasWorkflowNodesSlice
47+
const actionName = actionCreator.type.split('/').pop() as keyof typeof canvasWorkflowNodesActions;
48+
const canvasWorkflowAction = canvasWorkflowNodesActions[actionName];
49+
50+
if (canvasWorkflowAction && typeof canvasWorkflowAction === 'function') {
51+
dispatch(canvasWorkflowAction(action.payload as any));
52+
}
53+
}
54+
},
55+
});
56+
}
57+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { AppStartListening } from 'app/store/store';
2+
import { deepClone } from 'common/util/deepClone';
3+
import { selectCanvasWorkflow } from 'features/controlLayers/store/canvasWorkflowSlice';
4+
import { getFormFieldInitialValues } from 'features/nodes/store/nodesSlice';
5+
import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
6+
import type { AnyNode } from 'features/nodes/types/invocation';
7+
import { REMEMBER_REHYDRATED } from 'redux-remember';
8+
9+
/**
10+
* When the app rehydrates from storage, we need to populate the canvasWorkflowNodes
11+
* shadow slice if a canvas workflow was previously selected.
12+
*
13+
* This ensures that exposed fields are visible when the page loads with a workflow already selected.
14+
*/
15+
export const addCanvasWorkflowRehydratedListener = (startListening: AppStartListening) => {
16+
startListening({
17+
type: REMEMBER_REHYDRATED,
18+
effect: async (_action, { dispatch, getState }) => {
19+
const state = getState();
20+
const { workflow, inputNodeId } = state.canvasWorkflow;
21+
22+
// If there's a canvas workflow already selected, we need to load it into shadow nodes
23+
if (workflow && inputNodeId) {
24+
// Manually dispatch the fulfilled action to populate shadow nodes
25+
// We can't use the thunk because the workflow is already loaded
26+
dispatch({
27+
type: selectCanvasWorkflow.fulfilled.type,
28+
payload: {
29+
workflow,
30+
inputNodeId,
31+
outputNodeId: state.canvasWorkflow.outputNodeId,
32+
workflowId: state.canvasWorkflow.selectedWorkflowId,
33+
fieldValues: state.canvasWorkflow.fieldValues,
34+
},
35+
});
36+
}
37+
},
38+
});
39+
};

invokeai/frontend/web/src/app/store/store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSe
2525
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
2626
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
2727
import { canvasWorkflowSliceConfig } from 'features/controlLayers/store/canvasWorkflowSlice';
28+
import { canvasWorkflowNodesSliceConfig } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
2829
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
2930
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
3031
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
@@ -57,6 +58,8 @@ import { actionsDenylist } from './middleware/devtools/actionsDenylist';
5758
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
5859
import { addArchivedOrDeletedBoardListener } from './middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener';
5960
import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded';
61+
import { addCanvasWorkflowFieldChangedListener } from './middleware/listenerMiddleware/listeners/canvasWorkflowFieldChanged';
62+
import { addCanvasWorkflowRehydratedListener } from './middleware/listenerMiddleware/listeners/canvasWorkflowRehydrated';
6063

6164
export const listenerMiddleware = createListenerMiddleware();
6265

@@ -67,6 +70,7 @@ const SLICE_CONFIGS = {
6770
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
6871
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
6972
[canvasWorkflowSliceConfig.slice.reducerPath]: canvasWorkflowSliceConfig,
73+
[canvasWorkflowNodesSliceConfig.slice.reducerPath]: canvasWorkflowNodesSliceConfig,
7074
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
7175
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
7276
[configSliceConfig.slice.reducerPath]: configSliceConfig,
@@ -94,6 +98,7 @@ const ALL_REDUCERS = {
9498
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer,
9599
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer,
96100
[canvasWorkflowSliceConfig.slice.reducerPath]: canvasWorkflowSliceConfig.slice.reducer,
101+
[canvasWorkflowNodesSliceConfig.slice.reducerPath]: canvasWorkflowNodesSliceConfig.slice.reducer,
97102
// Undoable!
98103
[canvasSliceConfig.slice.reducerPath]: undoable(
99104
canvasSliceConfig.slice.reducer,
@@ -292,3 +297,7 @@ addAppConfigReceivedListener(startAppListening);
292297
addAdHocPostProcessingRequestedListener(startAppListening);
293298

294299
addSetDefaultSettingsListener(startAppListening);
300+
301+
// Canvas workflow fields
302+
addCanvasWorkflowFieldChangedListener(startAppListening);
303+
addCanvasWorkflowRehydratedListener(startAppListening);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import { Flex } from '@invoke-ai/ui-library';
3+
import { useAppSelector } from 'app/store/storeHooks';
4+
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
5+
import {
6+
ContainerContextProvider,
7+
DepthContextProvider,
8+
useContainerContext,
9+
useDepthContext,
10+
} from 'features/nodes/components/sidePanel/builder/contexts';
11+
import { isContainerElement } from 'features/nodes/types/workflow';
12+
import { CONTAINER_CLASS_NAME } from 'features/nodes/types/workflow';
13+
import { memo } from 'react';
14+
15+
import { CanvasWorkflowFormElementComponent } from './CanvasWorkflowFormElementComponent';
16+
17+
const containerViewModeSx: SystemStyleObject = {
18+
gap: 2,
19+
'&[data-self-layout="column"]': {
20+
flexDir: 'column',
21+
alignItems: 'stretch',
22+
},
23+
'&[data-self-layout="row"]': {
24+
flexDir: 'row',
25+
alignItems: 'flex-start',
26+
overflowX: 'auto',
27+
overflowY: 'visible',
28+
h: 'min-content',
29+
flexShrink: 0,
30+
},
31+
'&[data-parent-layout="column"]': {
32+
w: 'full',
33+
h: 'min-content',
34+
},
35+
'&[data-parent-layout="row"]': {
36+
flex: '1 1 0',
37+
minW: 32,
38+
},
39+
};
40+
41+
/**
42+
* Container element for canvas workflow fields.
43+
* This reads from the canvas workflow nodes slice.
44+
*/
45+
export const CanvasWorkflowContainerElement = memo(({ id }: { id: string }) => {
46+
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);
47+
const el = nodesState.form.elements[id];
48+
const depth = useDepthContext();
49+
const containerCtx = useContainerContext();
50+
51+
if (!el || !isContainerElement(el)) {
52+
return null;
53+
}
54+
55+
const { data } = el;
56+
const { children, layout } = data;
57+
58+
return (
59+
<DepthContextProvider depth={depth + 1}>
60+
<ContainerContextProvider id={id} layout={layout}>
61+
<Flex
62+
id={id}
63+
className={CONTAINER_CLASS_NAME}
64+
sx={containerViewModeSx}
65+
data-self-layout={layout}
66+
data-depth={depth}
67+
data-parent-layout={containerCtx.layout}
68+
>
69+
{children.map((childId) => (
70+
<CanvasWorkflowFormElementComponent key={childId} id={childId} />
71+
))}
72+
</Flex>
73+
</ContainerContextProvider>
74+
</DepthContextProvider>
75+
);
76+
});
77+
CanvasWorkflowContainerElement.displayName = 'CanvasWorkflowContainerElement';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useAppSelector } from 'app/store/storeHooks';
2+
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
3+
import type { FormElement } from 'features/nodes/types/workflow';
4+
import type { PropsWithChildren } from 'react';
5+
import { createContext, memo, useContext, useMemo } from 'react';
6+
7+
/**
8+
* Context that provides element lookup from canvas workflow nodes instead of regular nodes.
9+
* This ensures that when viewing canvas workflow fields, we read from the shadow slice.
10+
*/
11+
12+
type CanvasWorkflowElementContextValue = {
13+
getElement: (id: string) => FormElement | undefined;
14+
};
15+
16+
const CanvasWorkflowElementContext = createContext<CanvasWorkflowElementContextValue | null>(null);
17+
18+
export const CanvasWorkflowElementProvider = memo(({ children }: PropsWithChildren) => {
19+
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);
20+
21+
const value = useMemo<CanvasWorkflowElementContextValue>(
22+
() => ({
23+
getElement: (id: string) => nodesState.form.elements[id],
24+
}),
25+
[nodesState.form.elements]
26+
);
27+
28+
return <CanvasWorkflowElementContext.Provider value={value}>{children}</CanvasWorkflowElementContext.Provider>;
29+
});
30+
CanvasWorkflowElementProvider.displayName = 'CanvasWorkflowElementProvider';
31+
32+
/**
33+
* Hook to get an element, using canvas workflow context if available,
34+
* otherwise falls back to regular nodes.
35+
*/
36+
export const useCanvasWorkflowElement = (): ((id: string) => FormElement | undefined) | null => {
37+
return useContext(CanvasWorkflowElementContext)?.getElement ?? null;
38+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Flex, Text } from '@invoke-ai/ui-library';
2+
import { useAppSelector } from 'app/store/storeHooks';
3+
import { CanvasWorkflowModeProvider } from 'features/controlLayers/components/CanvasWorkflowModeContext';
4+
import { CanvasWorkflowRootContainer } from 'features/controlLayers/components/CanvasWorkflowRootContainer';
5+
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
6+
import { memo } from 'react';
7+
8+
/**
9+
* Renders the exposed fields for a canvas workflow.
10+
*
11+
* This component renders the workflow's form in view mode.
12+
* Each field element is wrapped with the appropriate InvocationNodeContext
13+
* in CanvasWorkflowFormElementComponent.
14+
*/
15+
export const CanvasWorkflowFieldsPanel = memo(() => {
16+
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);
17+
18+
// Check if form is empty
19+
const rootElement = nodesState.form.elements[nodesState.form.rootElementId];
20+
if (!rootElement || !('data' in rootElement) || !rootElement.data || !('children' in rootElement.data) || rootElement.data.children.length === 0) {
21+
return (
22+
<Flex w="full" p={4} justifyContent="center">
23+
<Text variant="subtext">No fields exposed in this workflow</Text>
24+
</Flex>
25+
);
26+
}
27+
28+
return (
29+
<CanvasWorkflowModeProvider>
30+
<Flex w="full" justifyContent="center" p={4}>
31+
<CanvasWorkflowRootContainer />
32+
</Flex>
33+
</CanvasWorkflowModeProvider>
34+
);
35+
});
36+
CanvasWorkflowFieldsPanel.displayName = 'CanvasWorkflowFieldsPanel';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useAppSelector } from 'app/store/storeHooks';
2+
import { selectCanvasWorkflowNodesSlice } from 'features/controlLayers/store/canvasWorkflowNodesSlice';
3+
import { DividerElement } from 'features/nodes/components/sidePanel/builder/DividerElement';
4+
import { HeadingElement } from 'features/nodes/components/sidePanel/builder/HeadingElement';
5+
import { NodeFieldElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementViewMode';
6+
import { TextElement } from 'features/nodes/components/sidePanel/builder/TextElement';
7+
import {
8+
isContainerElement,
9+
isDividerElement,
10+
isHeadingElement,
11+
isNodeFieldElement,
12+
isTextElement,
13+
} from 'features/nodes/types/workflow';
14+
import { memo } from 'react';
15+
import type { Equals } from 'tsafe';
16+
import { assert } from 'tsafe';
17+
18+
import { CanvasWorkflowContainerElement } from './CanvasWorkflowContainerElement';
19+
import { CanvasWorkflowInvocationNodeContextProvider } from './CanvasWorkflowInvocationContext';
20+
21+
/**
22+
* Renders a form element from canvas workflow nodes.
23+
* Recursively handles all element types.
24+
*/
25+
export const CanvasWorkflowFormElementComponent = memo(({ id }: { id: string }) => {
26+
const nodesState = useAppSelector(selectCanvasWorkflowNodesSlice);
27+
const el = nodesState.form.elements[id];
28+
29+
if (!el) {
30+
return null;
31+
}
32+
33+
if (isContainerElement(el)) {
34+
return <CanvasWorkflowContainerElement key={id} id={id} />;
35+
}
36+
37+
if (isNodeFieldElement(el)) {
38+
return (
39+
<CanvasWorkflowInvocationNodeContextProvider key={id} nodeId={el.data.fieldIdentifier.nodeId}>
40+
<NodeFieldElementViewMode el={el} />
41+
</CanvasWorkflowInvocationNodeContextProvider>
42+
);
43+
}
44+
45+
if (isDividerElement(el)) {
46+
return <DividerElement key={id} id={id} />;
47+
}
48+
49+
if (isHeadingElement(el)) {
50+
return <HeadingElement key={id} id={id} />;
51+
}
52+
53+
if (isTextElement(el)) {
54+
return <TextElement key={id} id={id} />;
55+
}
56+
57+
assert<Equals<typeof el, never>>(false, `Unhandled type for element with id ${id}`);
58+
});
59+
CanvasWorkflowFormElementComponent.displayName = 'CanvasWorkflowFormElementComponent';

0 commit comments

Comments
 (0)