);
}
diff --git a/src/components/data-exploration/cell-sets-tool/AnnotateClustersTool.jsx b/src/components/data-exploration/cell-sets-tool/AnnotateClustersTool.jsx
index 440e444915..15a9b97b9b 100644
--- a/src/components/data-exploration/cell-sets-tool/AnnotateClustersTool.jsx
+++ b/src/components/data-exploration/cell-sets-tool/AnnotateClustersTool.jsx
@@ -3,12 +3,29 @@ import PropTypes from 'prop-types';
import _ from 'lodash';
import {
+ Alert,
Button,
- Radio, Select, Space, Tooltip,
+ Input, Modal, Radio, Select, Space, Table, Tooltip, Typography,
} from 'antd';
-import { runCellSetsAnnotation } from 'redux/actions/cellSets';
+import { runCellSetsAnnotation, runCassiaAnnotation } from 'redux/actions/cellSets';
import { useDispatch } from 'react-redux';
+const { Text, Paragraph } = Typography;
+
+// Illustrative sample of what is actually sent to CASSIA's LLM provider: for
+// each cluster, only the marker gene *names*, ranked by avg_log2FC from highest
+// to lowest (top 50). The summary statistics (avg_log2FC, pct.1, pct.2) are used
+// only locally to rank and filter the genes — they are NOT included in the prompt.
+const exampleMarkerColumns = [
+ { title: 'cluster', dataIndex: 'cluster', key: 'cluster' },
+ { title: 'ranked marker genes (highest → lowest)', dataIndex: 'genes', key: 'genes' },
+];
+
+const exampleMarkerData = [
+ { key: 1, cluster: 1, genes: 'CD3D, IL7R, CD3E, TRAC, CD2, LTB, … (top 50)' },
+ { key: 2, cluster: 2, genes: 'MS4A1, CD79A, CD79B, CD19, HLA-DRA, TCL1A, … (top 50)' },
+];
+
const tissueOptions = [
'Immune system',
'Pancreas',
@@ -59,54 +76,221 @@ const scTypeTooltipText = (
>
);
+const cassiaTooltipText = (
+ <>
+ Automatic annotation is performed using CASSIA, a multi-agent large language
+ model system for interpretable cell type annotation developed by Elliot Xie
+ et al. It computes marker genes per cluster and uses an LLM to predict cell
+ types. Enter the species and tissue as free text (e.g. "Human"
+ and "Large Intestine").
+ More details can be found in
+ {' '}
+ the CASSIA github repo
+ .
+ >
+);
+
+const ANNOTATION_METHODS = {
+ SCTYPE: 'sctype',
+ CASSIA: 'cassia',
+};
+
const AnnotateClustersTool = ({ experimentId, onRunAnnotation }) => {
const dispatch = useDispatch();
+ const [method, setMethod] = useState(ANNOTATION_METHODS.CASSIA);
const [tissue, setTissue] = useState(null);
const [species, setSpecies] = useState(null);
+ const [additionalInfo, setAdditionalInfo] = useState('');
+ const [cassiaModalVisible, setCassiaModalVisible] = useState(false);
+
+ const isCassia = method === ANNOTATION_METHODS.CASSIA;
+
+ // tissue/species have different valid values per method (enum vs free text)
+ const onMethodChange = (e) => {
+ setMethod(e.target.value);
+ setTissue(null);
+ setSpecies(null);
+ setAdditionalInfo('');
+ };
+
+ const onCompute = () => {
+ // CASSIA sends data to an external LLM provider, so confirm first
+ if (isCassia) {
+ setCassiaModalVisible(true);
+ return;
+ }
+ dispatch(runCellSetsAnnotation(experimentId, species, tissue));
+ onRunAnnotation();
+ };
+
+ const onConfirmCassia = () => {
+ setCassiaModalVisible(false);
+ dispatch(runCassiaAnnotation(experimentId, species, tissue, additionalInfo));
+ onRunAnnotation();
+ };
return (
-
+
+
+ CASSIA
+
- ScType
+ ScType
Tissue type:
-
Species:
- ({ label: option, value: option }))}
- value={species}
- placeholder='Select a species'
- onChange={setSpecies}
- style={{ width: '100%' }}
- size='small'
- />
+ {isCassia ? (
+ setSpecies(e.target.value || null)}
+ style={{ width: '100%' }}
+ size='small'
+ />
+ ) : (
+ ({ label: option, value: option }))}
+ value={species}
+ placeholder='Select a species'
+ onChange={setSpecies}
+ style={{ width: '100%' }}
+ size='small'
+ />
+ )}
+ {isCassia && (
+
+ Additional context (optional):
+ setAdditionalInfo(e.target.value)}
+ autoSize={{ minRows: 2, maxRows: 5 }}
+ style={{ width: '100%' }}
+ size='small'
+ />
+
+ )}
+
+
+ setCassiaModalVisible(false)}
+ onOk={onConfirmCassia}
+ okText='Continue'
+ cancelText='Cancel'
+ width={640}
+ >
+
+
+ CASSIA uses a large language model hosted on Amazon Bedrock to
+ annotate your clusters. Continuing will send the data below
+ outside Cellenics to that service.
+ {' '}
+ Per
+ {' '}
+
+ AWS
+
+ , your inputs and the model outputs are not shared with the
+ model providers and are not used to train any models, and the
+ data is encrypted in transit and at rest within the AWS region.
+ >
+ )}
+ />
+
+
+ Only the following is sent — no raw expression counts and no
+ cell-level data leave Cellenics:
+
+
+ the
+ {' '}
+ names of the top marker genes for each cluster
+ {' '}
+ (ranked by fold-change, highest first), in the format shown
+ below — the underlying summary statistics are used only to rank
+ and filter the genes and are not sent
+
+
+ the tissue type
+ {' '}
+ {tissue}
+ {' '}
+ and species
+ {' '}
+ {species}
+
+ {additionalInfo.trim() && (
+
+ the additional context you entered:
+ {' '}
+ {additionalInfo.trim()}
+
+ )}
+
+
+
+
+
+ Example of the ranked marker gene list sent per cluster
+ (illustrative genes):
+
+
+
+
+
);
};
diff --git a/src/components/data-exploration/embedding/Embedding.jsx b/src/components/data-exploration/embedding/Embedding.jsx
index e870bd22f0..65647aea4d 100644
--- a/src/components/data-exploration/embedding/Embedding.jsx
+++ b/src/components/data-exploration/embedding/Embedding.jsx
@@ -163,8 +163,14 @@ const Embedding = (props) => {
useEffect(() => {
if (!data || !cellSetHidden || !cellSetProperties) return;
+ // Positions depend only on the embedding data and which cells are hidden;
+ // cellSetProperties is read for the hidden sets' (stable) cellIds but is
+ // intentionally NOT a dependency, so a rename/recolor doesn't rebuild the
+ // position arrays for every cell. cellSetHidden's reference only changes on
+ // hide/unhide/delete, and data on reclustering.
setConvertedCellsData(convertCellsData(data, cellSetHidden, cellSetProperties));
- }, [data, cellSetHidden, cellSetProperties]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data, cellSetHidden]);
const totalNumCells = useMemo(() => data?.length, [data]);
diff --git a/src/components/data-exploration/spatial/SpatialViewer.jsx b/src/components/data-exploration/spatial/SpatialViewer.jsx
index fe4f14f521..6be5da3f4e 100644
--- a/src/components/data-exploration/spatial/SpatialViewer.jsx
+++ b/src/components/data-exploration/spatial/SpatialViewer.jsx
@@ -97,27 +97,35 @@ const SpatialViewer = (props) => {
hidden: cellSetHidden,
} = cellSets;
+ // Only recompute when the set of hidden cell sets changes (its reference is
+ // stable across rename/recolor); cellSetProperties is read for the hidden
+ // sets' stable cellIds but is intentionally not a dependency.
const hiddenCellIds = useMemo(() => {
if (!cellSetHidden || !cellSetProperties) return new Set();
return union([...cellSetHidden], cellSetProperties);
- }, [cellSetHidden, cellSetProperties]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cellSetHidden]);
// Cells absent from every 'cellSets'-type cluster (louvain, leiden, …) were
// filtered out during the analysis pipeline. They must never be rendered or
// contribute to the layout, regardless of the active colouring scheme or
// which cell sets are hidden.
+ // Key off the RAW hierarchy (referentially stable across rename/recolor),
+ // not getCellSetsHierarchyByType — that derived selector embeds cell set
+ // names, so it produces a new array on every rename and would otherwise
+ // rebuild this O(all cells) set (and the colour LUTs / deck.gl layers that
+ // depend on it). Type and cellIds are read from properties (stable values).
const cellsInAnyCluster = useMemo(() => {
const validIds = new Set();
- cellSetsHierarchyNodes.forEach(({ children }) => {
- children?.forEach(({ key }) => {
- const props = cellSetProperties[key];
- if (props?.cellIds) {
- props.cellIds.forEach((id) => validIds.add(id));
- }
+ cellSetHierarchy.forEach(({ key, children }) => {
+ if (cellSetProperties[key]?.type !== 'cellSets') return;
+ children?.forEach(({ key: childKey }) => {
+ cellSetProperties[childKey]?.cellIds?.forEach((id) => validIds.add(id));
});
});
return validIds;
- }, [cellSetsHierarchyNodes, cellSetProperties]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cellSetHierarchy]);
const selectedCell = useSelector((state) => state.cellInfo.cellId);
const hoverSource = useSelector((state) => state.cellInfo.hoverSource);
@@ -264,13 +272,18 @@ const SpatialViewer = (props) => {
}, [spatialSettings]);
// ── Centroid offsets ──────────────────────────────────────────────────────
+ // Centroids depend on the embedding data and per-sample cellIds (stable
+ // across rename/recolor); cellSetProperties is read for the sample cellIds
+ // but is intentionally not a dependency so a rename doesn't recompute every
+ // cell's centroid.
useEffect(() => {
if (!data || !omeZarrSampleIds.length || !cellSetProperties || !perImageShape || !gridShape) return;
if (omeZarrSampleIds.some((id) => !cellSetProperties[id])) return;
setOffsetData(offsetCentroids(
data, cellSetProperties, omeZarrSampleIds, perImageShape, gridShape, gridLayout.sampleRowCol,
));
- }, [data, omeZarrSampleIds, cellSetProperties, perImageShape, gridShape, gridLayout]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data, omeZarrSampleIds, perImageShape, gridShape, gridLayout]);
// ── URL fetching ──────────────────────────────────────────────────────────
useEffect(() => {
@@ -435,7 +448,9 @@ const SpatialViewer = (props) => {
// ── Initial view state ────────────────────────────────────────────────────
useEffect(() => {
if (!loader || !width || !height || viewState) return;
- setViewState(getDefaultInitialViewState(loader.data, { width, height }, 0.5));
+ // zoomBackOff (3rd arg) is subtracted from the fit zoom; 0 = tight fit that
+ // fills the viewport (larger values leave more empty margin).
+ setViewState(getDefaultInitialViewState(loader.data, { width, height }, 0));
}, [loader, width, height]);
// ── Initial view state (imageless / micron-driven) ────────────────────────
@@ -456,8 +471,8 @@ const SpatialViewer = (props) => {
const target = [(minX + maxX) / 2, (minY + maxY) / 2, 0];
const extentX = Math.max(maxX - minX, 1);
const extentY = Math.max(maxY - minY, 1);
- // zoom such that the data extent fits the viewport (with a little padding)
- const zoom = Math.log2(Math.min(width / extentX, height / extentY)) - 0.2;
+ // zoom such that the data extent fills the viewport (tight fit)
+ const zoom = Math.log2(Math.min(width / extentX, height / extentY));
return { target, zoom };
}, [offsetData, width, height, hiddenCellIds, cellsInAnyCluster]);
@@ -469,7 +484,7 @@ const SpatialViewer = (props) => {
} else if (segmentationsLoader && width && height) {
// offset centroids not ready yet: centre on the segmentation bitmask so
// the polygons are visible even before the centroid overlay is built
- setViewState(getDefaultInitialViewState(segmentationsLoader.data, { width, height }, 0.5));
+ setViewState(getDefaultInitialViewState(segmentationsLoader.data, { width, height }, 0));
}
}, [isImageless, fitViewToOffsetData, viewState, segmentationsLoader, width, height]);
@@ -685,7 +700,7 @@ const SpatialViewer = (props) => {
return;
}
if (!loader || !width || !height) return;
- setViewState(getDefaultInitialViewState(loader.data, { width, height }, 0.5));
+ setViewState(getDefaultInitialViewState(loader.data, { width, height }, 0));
}, [loader, width, height, isImageless, fitViewToOffsetData]);
// ── Molecule overlay: per-sample grid offsets + colour lookup ─────────────
diff --git a/src/components/data-exploration/useFocusCellColors.js b/src/components/data-exploration/useFocusCellColors.js
index de138eaf05..856b5f4127 100644
--- a/src/components/data-exploration/useFocusCellColors.js
+++ b/src/components/data-exploration/useFocusCellColors.js
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { loadGeneExpression } from 'redux/actions/genes';
@@ -31,8 +31,29 @@ const useFocusCellColors = ({
const dispatch = useDispatch();
const [cellColors, setCellColors] = useState({});
- // Focus change (a cell set / gene / metadata got selected, or cleared); also
- // re-runs when the cell set properties or hierarchy change.
+ // Name-independent signature of the focused cell class's colouring: the
+ // focus target plus each child's colour and cell count. It changes on focus
+ // change / recolour / add / remove / membership change — but NOT on a rename.
+ // Keying the colouring effect on this (instead of the whole cellSetProperties
+ // object) means renaming a cell set no longer recomputes the colour of every
+ // cell and re-uploads the deck.gl colour buffer. Cheap: O(children).
+ const coloringSignature = useMemo(() => {
+ const { store, key } = focusData;
+ if (store !== 'cellSets') return `${store}:${key}`;
+
+ const node = cellSetHierarchy?.find((rootNode) => rootNode.key === key);
+ const children = node?.children ?? [];
+ const childSignature = children
+ .map((child) => {
+ const property = cellSetProperties[child.key];
+ return `${child.key}=${property?.color}#${property?.cellIds?.length}`;
+ })
+ .join(',');
+ return `cellSets:${key}:${childSignature}`;
+ }, [focusData, cellSetHierarchy, cellSetProperties]);
+
+ // Recolour cells when the focus target or its colouring changes (see
+ // coloringSignature). Reads the current hierarchy/properties inside.
useEffect(() => {
const { store, key } = focusData;
@@ -52,7 +73,7 @@ const useFocusCellColors = ({
if (onFocusChange) onFocusChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [focusData, cellSetHierarchy, cellSetProperties]);
+ }, [coloringSignature]);
// Focused gene's expression finished loading → colour cells by it.
useEffect(() => {
diff --git a/src/redux/actions/cellSets/deleteCellClass.js b/src/redux/actions/cellSets/deleteCellClass.js
index b196c321f4..099acc7eaa 100644
--- a/src/redux/actions/cellSets/deleteCellClass.js
+++ b/src/redux/actions/cellSets/deleteCellClass.js
@@ -3,6 +3,7 @@ import { CELL_CLASS_DELETE } from 'redux/actionTypes/cellSets';
import endUserMessages from 'utils/endUserMessages';
import fetchAPI from 'utils/http/fetchAPI';
import handleError from 'utils/http/handleError';
+import loadCellSets from 'redux/actions/cellSets/loadCellSets';
const deleteCellClassJsonMerger = (cellClasskey) => (
[{
@@ -27,6 +28,14 @@ const deleteCellSet = (
return null;
}
+ // Optimistically remove the cell class from the UI right away. The API PATCH
+ // rewrites the whole cell sets object (slow for large cell sets), so we don't
+ // block the UI on it; on failure we reload to restore the server's truth.
+ dispatch({
+ type: CELL_CLASS_DELETE,
+ payload: { key: cellClassKey },
+ });
+
try {
await fetchAPI(
`/v2/experiments/${experimentId}/cellSets`,
@@ -38,13 +47,9 @@ const deleteCellSet = (
body: JSON.stringify(deleteCellClassJsonMerger(cellClassKey)),
},
);
-
- await dispatch({
- type: CELL_CLASS_DELETE,
- payload: { key: cellClassKey },
- });
} catch (e) {
handleError(e, endUserMessages.ERROR_SAVING);
+ dispatch(loadCellSets(experimentId, true));
}
};
diff --git a/src/redux/actions/cellSets/deleteCellSet.js b/src/redux/actions/cellSets/deleteCellSet.js
index d0eae41434..059bc1eba7 100644
--- a/src/redux/actions/cellSets/deleteCellSet.js
+++ b/src/redux/actions/cellSets/deleteCellSet.js
@@ -3,6 +3,7 @@ import { CELL_SETS_DELETE } from 'redux/actionTypes/cellSets';
import endUserMessages from 'utils/endUserMessages';
import fetchAPI from 'utils/http/fetchAPI';
import handleError from 'utils/http/handleError';
+import loadCellSets from 'redux/actions/cellSets/loadCellSets';
const deleteCellSetJsonMerger = (cellSetKey, cellClasskey) => (
[{
@@ -33,6 +34,14 @@ const deleteCellSet = (experimentId, key) => async (dispatch, getState) => {
return null;
}
+ // Optimistically remove the cell set from the UI right away; the API PATCH
+ // rewrites the whole cell sets object (slow for large cell sets) so we don't
+ // block the UI on it. On failure we reload to restore the server's truth.
+ dispatch({
+ type: CELL_SETS_DELETE,
+ payload: { key },
+ });
+
try {
await fetchAPI(
`/v2/experiments/${experimentId}/cellSets`,
@@ -46,13 +55,9 @@ const deleteCellSet = (experimentId, key) => async (dispatch, getState) => {
),
},
);
-
- await dispatch({
- type: CELL_SETS_DELETE,
- payload: { key },
- });
} catch (e) {
handleError(e, endUserMessages.ERROR_SAVING);
+ dispatch(loadCellSets(experimentId, true));
}
};
diff --git a/src/redux/actions/cellSets/index.js b/src/redux/actions/cellSets/index.js
index ca10778c73..441499d4dd 100644
--- a/src/redux/actions/cellSets/index.js
+++ b/src/redux/actions/cellSets/index.js
@@ -3,6 +3,7 @@ import deleteCellSet from './deleteCellSet';
import deleteCellClass from './deleteCellClass';
import runCellSetsClustering from './runCellSetsClustering';
import runCellSetsAnnotation from './runCellSetsAnnotation';
+import runCassiaAnnotation from './runCassiaAnnotation';
import updateCellSetsClustering from './updateCellSetsClustering';
import loadCellSets from './loadCellSets';
@@ -18,6 +19,7 @@ export {
deleteCellClass,
runCellSetsClustering,
runCellSetsAnnotation,
+ runCassiaAnnotation,
updateCellSetsClustering,
loadCellSets,
reorderCellSet,
diff --git a/src/redux/actions/cellSets/runCassiaAnnotation.js b/src/redux/actions/cellSets/runCassiaAnnotation.js
new file mode 100644
index 0000000000..a30e007e0a
--- /dev/null
+++ b/src/redux/actions/cellSets/runCassiaAnnotation.js
@@ -0,0 +1,56 @@
+import {
+ CELL_SETS_ERROR, CELL_SETS_CLUSTERING_UPDATING,
+} from 'redux/actionTypes/cellSets';
+
+import getTimeoutForWorkerTask from 'utils/getTimeoutForWorkerTask';
+import fetchWork from 'utils/work/fetchWork';
+import handleError from 'utils/http/handleError';
+import endUserMessages from 'utils/endUserMessages';
+import updateCellSetsClustering from 'redux/actions/cellSets/updateCellSetsClustering';
+
+const runCassiaAnnotation = (
+ experimentId, species, tissue, additionalInfo = '',
+) => async (dispatch, getState) => {
+ const { error, updatingClustering, loading } = getState().cellSets;
+
+ if ((loading && updatingClustering) || error) return;
+
+ const body = {
+ name: 'CASSIAAnnotate',
+ species,
+ tissue,
+ additionalInfo,
+ };
+
+ dispatch({
+ type: CELL_SETS_CLUSTERING_UPDATING,
+ });
+
+ const timeout = getTimeoutForWorkerTask(getState(), 'ClusterCells');
+
+ try {
+ await fetchWork(
+ experimentId,
+ body,
+ getState,
+ dispatch,
+ {
+ timeout,
+ broadcast: true,
+ },
+ );
+ } catch (e) {
+ const errorMessage = handleError(e, endUserMessages.ERROR_CASSIA_ANNOTATION_FAILED);
+ dispatch({
+ type: CELL_SETS_ERROR,
+ payload: {
+ experimentId,
+ error: errorMessage,
+ },
+ });
+ // To set the clustering status back to false
+ dispatch(updateCellSetsClustering(experimentId));
+ }
+};
+
+export default runCassiaAnnotation;
diff --git a/src/redux/actions/cellSets/updateCellSetProperty.js b/src/redux/actions/cellSets/updateCellSetProperty.js
index 072850bae3..1befe4b017 100644
--- a/src/redux/actions/cellSets/updateCellSetProperty.js
+++ b/src/redux/actions/cellSets/updateCellSetProperty.js
@@ -5,6 +5,7 @@ import { CELL_SETS_UPDATE_PROPERTY } from 'redux/actionTypes/cellSets';
import fetchAPI from 'utils/http/fetchAPI';
import endUserMessages from 'utils/endUserMessages';
import handleError from 'utils/http/handleError';
+import loadCellSets from 'redux/actions/cellSets/loadCellSets';
const updateCellSetPropertyJsonMerger = (cellSetKey, dataUpdated, cellClassKey) => (
[{
@@ -63,6 +64,17 @@ const updateCellSetProperty = (
? updateCellClassPropertyJsonMerger(key, dataUpdated)
: updateCellSetPropertyJsonMerger(key, dataUpdated, parentNodeKey);
+ // Optimistically update the UI (e.g. the new name/color) right away; the API
+ // PATCH rewrites the whole cell sets object so we don't block the UI on it.
+ // On failure we reload to restore the server's truth.
+ dispatch({
+ type: CELL_SETS_UPDATE_PROPERTY,
+ payload: {
+ cellSetKey: key,
+ dataUpdated,
+ },
+ });
+
try {
await fetchAPI(
`/v2/experiments/${experimentId}/cellSets`,
@@ -74,16 +86,9 @@ const updateCellSetProperty = (
body: JSON.stringify(jsonMergerUpdateObject),
},
);
-
- await dispatch({
- type: CELL_SETS_UPDATE_PROPERTY,
- payload: {
- cellSetKey: key,
- dataUpdated,
- },
- });
} catch (e) {
handleError(e, endUserMessages.ERROR_SAVING);
+ dispatch(loadCellSets(experimentId, true));
}
};
diff --git a/src/redux/reducers/componentConfig/cellClassDelete.js b/src/redux/reducers/componentConfig/cellClassDelete.js
index 299208f35b..22101d06ea 100644
--- a/src/redux/reducers/componentConfig/cellClassDelete.js
+++ b/src/redux/reducers/componentConfig/cellClassDelete.js
@@ -10,7 +10,7 @@ const cellClassDelete = produce((draft, action) => {
const { key: cellClassKey } = action.payload;
Object.values(draft).forEach((value) => {
- if (original(value)?.config?.groupedTracks.includes(cellClassKey)) {
+ if (original(value)?.config?.groupedTracks?.includes(cellClassKey)) {
_.pull(value.config.groupedTracks, cellClassKey);
}
});
diff --git a/src/redux/reducers/layout/initialState.js b/src/redux/reducers/layout/initialState.js
index 55692e3311..06bb556e10 100644
--- a/src/redux/reducers/layout/initialState.js
+++ b/src/redux/reducers/layout/initialState.js
@@ -3,7 +3,7 @@ const initialLayoutSpatial = {
direction: 'row',
first: {
first: {
- first: 'UMAP', second: 'Spatial', direction: 'row', splitPercentage: 62,
+ first: 'UMAP', second: 'Spatial', direction: 'row', splitPercentage: 42,
},
second: {
first: 'Heatmap',
diff --git a/src/redux/store.js b/src/redux/store.js
index 1065051023..6b9b06313a 100644
--- a/src/redux/store.js
+++ b/src/redux/store.js
@@ -8,6 +8,31 @@ import rootReducer from 'redux/reducers/index';
enableMapSet();
+// The cellSets state holds a `cellIds` integer array per cell set, totalling
+// millions of entries for large experiments. Redux DevTools (which snapshots
+// state after every action for time-travel) and redux-logger would otherwise
+// serialize all of them on every single action, making cheap operations like
+// rename/delete/select lag for seconds. Replace the arrays with a compact
+// placeholder before they are handed to the dev tools. This is O(number of
+// cell sets) and never touches the real store state.
+const sanitizeCellSetsState = (state) => {
+ const properties = state?.cellSets?.properties;
+ if (!properties) return state;
+
+ const sanitizedProperties = {};
+ Object.keys(properties).forEach((key) => {
+ const property = properties[key];
+ sanitizedProperties[key] = property?.cellIds
+ ? { ...property, cellIds: `[${property.cellIds.length} cellIds]` }
+ : property;
+ });
+
+ return {
+ ...state,
+ cellSets: { ...state.cellSets, properties: sanitizedProperties },
+ };
+};
+
const bindMiddleware = (middleware) => {
const { composeWithDevTools } = require('redux-devtools-extension');
@@ -17,9 +42,12 @@ const bindMiddleware = (middleware) => {
// do not log server-side redux actions
middleware.push(createLogger({
predicate: () => typeof window !== 'undefined',
+ stateTransformer: sanitizeCellSetsState,
}));
- return composeWithDevTools(applyMiddleware(...middleware));
+ return composeWithDevTools({
+ stateSanitizer: sanitizeCellSetsState,
+ })(applyMiddleware(...middleware));
};
const makeStore = () => {
diff --git a/src/utils/cellSets/composeTree.js b/src/utils/cellSets/composeTree.js
index 099e4df0e6..13b35a2142 100644
--- a/src/utils/cellSets/composeTree.js
+++ b/src/utils/cellSets/composeTree.js
@@ -21,7 +21,11 @@ const composeTree = (hierarchy, properties, filterTypes = null) => {
return ({
...node,
...restOfProperties,
- cellIds: [...properties[node.key]?.cellIds || []],
+ // Share the (immutable, immer-frozen) cellIds array by reference
+ // rather than spread-copying it. Copying every cell id on each tree
+ // rebuild is O(total cells) and made rename/recolor/delete/select lag
+ // on large experiments; nothing mutates these arrays.
+ cellIds: properties[node.key]?.cellIds || [],
children: node.children ? composeTreeRecursive(node.children, null) : undefined,
});
},
diff --git a/src/utils/constants.js b/src/utils/constants.js
index e5044e9ea8..faafb1186f 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -121,6 +121,7 @@ const downsamplingMethods = {
const cellSetsUpdatedMessages = {
ClusterCells: endUserMessages.SUCCESS_CELL_SETS_RECLUSTERED,
ScTypeAnnotate: endUserMessages.SUCCESS_CELL_SETS_ANNOTATED,
+ CASSIAAnnotate: endUserMessages.SUCCESS_CELL_SETS_ANNOTATED,
};
export {
diff --git a/src/utils/endUserMessages.jsx b/src/utils/endUserMessages.jsx
index bd64530d52..2cda645a84 100644
--- a/src/utils/endUserMessages.jsx
+++ b/src/utils/endUserMessages.jsx
@@ -66,4 +66,5 @@ export default {
ERROR_CLONING_DEFAULT: 'We couldn\'t clone the project.',
ERROR_CLONING_PIPELINE_LOCKED: 'We couldn\'t clone the project because data processing is currently running, wait until it finishes and try again',
ERROR_CELL_SETS_ANNOTATION_FAILED: 'ScType annotation unsuccessful. Please ensure dataset contains gene symbols.',
+ ERROR_CASSIA_ANNOTATION_FAILED: 'CASSIA annotation unsuccessful. Please ensure dataset contains gene symbols and try again.',
};
diff --git a/src/utils/experimentUpdatesHandler.js b/src/utils/experimentUpdatesHandler.js
index af272f663c..254866f6c1 100644
--- a/src/utils/experimentUpdatesHandler.js
+++ b/src/utils/experimentUpdatesHandler.js
@@ -101,7 +101,7 @@ const onWorkResponseUpdate = (update, dispatch, experimentId) => {
request: { body: { name: workRequestName } },
} = update;
- if (['ClusterCells', 'ScTypeAnnotate'].includes(workRequestName)) {
+ if (['ClusterCells', 'ScTypeAnnotate', 'CASSIAAnnotate'].includes(workRequestName)) {
dispatch(updateCellSetsClustering(experimentId));
pushNotificationMessage('success', cellSetsUpdatedMessages[workRequestName]);
}
diff --git a/src/utils/work/waitForWorkRequest.js b/src/utils/work/waitForWorkRequest.js
index 1317e1bb59..01a865e25e 100644
--- a/src/utils/work/waitForWorkRequest.js
+++ b/src/utils/work/waitForWorkRequest.js
@@ -80,6 +80,9 @@ const waitForWorkRequest = async (
worker: {
statusCode: message.status_code,
userMessage: message.user_message,
+ // int 0-100 when the task reports a bar-able progress, else null
+ // (null resets any previous bar for message-only phases)
+ progressPercent: message.progress_percent ?? null,
},
};
dispatch(updateBackendStatus(experimentId, status));