+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx
new file mode 100644
index 000000000000..473f60cd8a13
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValue.tsx
@@ -0,0 +1,70 @@
+/**
+ * This is similar to ValueView or CellValue - it dispatches
+ * to the appropriate component for rendering based on the value type.
+ */
+import React from 'react';
+
+import {parseRef} from '../../../../../react';
+import {UserLink} from '../../../../UserLink';
+import {SmallRef} from '../../Browse2/SmallRef';
+import {ObjectPath} from '../pages/CallPage/traverse';
+import {ValueViewNumber} from '../pages/CallPage/ValueViewNumber';
+import {
+ isProbablyTimestampMs,
+ isProbablyTimestampSec,
+} from '../pages/CallPage/ValueViewNumberTimestamp';
+import {ValueViewPrimitive} from '../pages/CallPage/ValueViewPrimitive';
+import {MISSING} from './compare';
+import {CompareGridCellValueCode} from './CompareGridCellValueCode';
+import {CompareGridCellValueTimestamp} from './CompareGridCellValueTimestamp';
+import {RESOLVED_REF_KEY} from './refUtil';
+
+type CompareGridCellValueProps = {
+ path: ObjectPath;
+ value: any;
+ valueType: any;
+};
+
+export const CompareGridCellValue = ({
+ path,
+ value,
+ valueType,
+}: CompareGridCellValueProps) => {
+ if (value === MISSING) {
+ return missing;
+ }
+ if (value === undefined) {
+ return undefined;
+ }
+ if (value === null) {
+ return null;
+ }
+ if (path.toString() === 'wb_user_id') {
+ return ;
+ }
+ if (valueType === 'code') {
+ return ;
+ }
+ if (valueType === 'object') {
+ if (RESOLVED_REF_KEY in value) {
+ return ;
+ }
+ // We don't need to show anything for this row because user can expand it to compare child keys
+ return null;
+ }
+ if (valueType === 'array') {
+ return array;
+ }
+
+ if (valueType === 'number') {
+ if (isProbablyTimestampSec(value)) {
+ return ;
+ }
+ if (isProbablyTimestampMs(value)) {
+ return ;
+ }
+ return ;
+ }
+
+ return
{value}
;
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueCode.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueCode.tsx
new file mode 100644
index 000000000000..b6cfea71f97a
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueCode.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import {Browse2OpDefCode} from '../../Browse2/Browse2OpDefCode';
+
+type CompareGridCellValueCodeProps = {
+ value: string;
+};
+
+export const CompareGridCellValueCode = ({
+ value,
+}: CompareGridCellValueCodeProps) => {
+ // Negative margin used to invert (mostly) the padding that we add to other cell types
+ // TODO: For better code layout/dependency management might be better to duplicate
+ // Browse2OpDefCode instead of referencing something in Browse2.
+ return (
+
+
+
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx
new file mode 100644
index 000000000000..953af39c0957
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridCellValueTimestamp.tsx
@@ -0,0 +1,39 @@
+/**
+ * When we have an integer that appears to be a timestamp, we display it as a
+ * nice date, but allow clicking to toggle between that and the raw value.
+ */
+import React, {useState} from 'react';
+
+import {Timestamp} from '../../../../Timestamp';
+import {Tooltip} from '../../../../Tooltip';
+
+type CompareGridCellValueTimestampProps = {
+ value: number;
+ unit: 'ms' | 's';
+};
+
+export const CompareGridCellValueTimestamp = ({
+ value,
+ unit,
+}: CompareGridCellValueTimestampProps) => {
+ const [showRaw, setShowRaw] = useState(false);
+
+ let body = null;
+ if (showRaw) {
+ body = (
+ {value}}
+ content="Click to format as date"
+ />
+ );
+ } else {
+ const tsValue = unit === 'ms' ? value / 1000 : value;
+ body = ;
+ }
+
+ return (
+
setShowRaw(!showRaw)}>
+ {body}
+
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx
new file mode 100644
index 000000000000..80e1bcc6f0a3
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridGroupingCell.tsx
@@ -0,0 +1,130 @@
+/**
+ * This is the component used to render the left-most "tree" part of the grid,
+ * handling indentation and expansion.
+ */
+
+import {Box, BoxProps} from '@mui/material';
+import {GridRenderCellParams, useGridApiContext} from '@mui/x-data-grid-pro';
+import _ from 'lodash';
+import React, {FC, MouseEvent} from 'react';
+
+import {GOLD_400, MOON_600} from '../../../../../common/css/color.styles';
+import {Button} from '../../../../Button';
+import {Tooltip} from '../../../../Tooltip';
+import {CursorBox} from '../pages/CallPage/CursorBox';
+import {UNCHANGED} from './compare';
+
+const INSET_SPACING = 32;
+
+export const CompareGridGroupingCell: FC<
+ GridRenderCellParams & {onClick?: (event: MouseEvent) => void}
+> = props => {
+ const {id, field, rowNode, row} = props;
+ const isGroup = rowNode.type === 'group';
+ const hasExpandableRefs = row.expandableRefs.length > 0;
+ const isExpandable = (isGroup || hasExpandableRefs) && !row.isCode;
+ const apiRef = useGridApiContext();
+ const onClick: BoxProps['onClick'] = event => {
+ if (isGroup) {
+ apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded);
+ apiRef.current.setCellFocus(id, field);
+ }
+
+ if (props.onClick) {
+ props.onClick(event);
+ }
+
+ event.stopPropagation();
+ };
+
+ const tooltipContent = row.path ? row.path.toString() : undefined;
+ const box = (
+
+ {_.range(rowNode.depth).map(i => {
+ return (
+
+ );
+ })}
+
+ {isExpandable ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ {props.value}
+
+ }
+ />
+
+ );
+
+ return box;
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPill.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPill.tsx
new file mode 100644
index 000000000000..b91db8babea1
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPill.tsx
@@ -0,0 +1,43 @@
+/**
+ * A small colored pill indicating the delta between two values.
+ */
+
+import React from 'react';
+
+import {monthRoundedTime} from '../../../../../common/util/time';
+import {Pill} from '../../../../Tag';
+import {
+ isProbablyTimestampMs,
+ isProbablyTimestampSec,
+} from '../pages/CallPage/ValueViewNumberTimestamp';
+import {CompareGridPillNumber} from './CompareGridPillNumber';
+
+type CompareGridPillProps = {
+ value: any;
+ valueType: any;
+ compareValue: any;
+ compareValueType: any;
+};
+
+export const CompareGridPill = (props: CompareGridPillProps) => {
+ const {value, valueType, compareValue, compareValueType} = props;
+ if (valueType !== 'number' || compareValueType !== 'number') {
+ return null;
+ }
+ if (value === compareValue) {
+ return null;
+ }
+ if (isProbablyTimestampMs(value) && isProbablyTimestampMs(compareValue)) {
+ const difference = value - compareValue;
+ const formatted = monthRoundedTime(Math.abs(difference / 1000));
+ const color = difference > 0 ? 'green' : 'red';
+ return ;
+ }
+ if (isProbablyTimestampSec(value) && isProbablyTimestampSec(compareValue)) {
+ const difference = value - compareValue;
+ const formatted = monthRoundedTime(Math.abs(difference));
+ const color = difference > 0 ? 'green' : 'red';
+ return ;
+ }
+ return ;
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPillNumber.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPillNumber.tsx
new file mode 100644
index 000000000000..7ad9071845b1
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/CompareGridPillNumber.tsx
@@ -0,0 +1,69 @@
+/**
+ * A small colored pill indicating the delta between two numbers,
+ * allowing cycling between different representations such as absolute
+ * or percentage difference.
+ */
+
+import React, {useState} from 'react';
+
+import {Pill, TagColorName} from '../../../../Tag';
+import {Tooltip} from '../../../../Tooltip';
+
+type CompareGridPillNumberProps = {
+ value: any;
+ valueType: any;
+ compareValue: any;
+ compareValueType: any;
+};
+
+export const CompareGridPillNumber = ({
+ value,
+ compareValue,
+}: CompareGridPillNumberProps) => {
+ const [diffModeIdx, setDiffModeIdx] = useState(0);
+ const difference = value - compareValue;
+ const isIncrease = difference > 0;
+ const modes = ['absolute', 'percentage'];
+ if (compareValue !== 0 && isIncrease) {
+ modes.push('ratio');
+ }
+ const mode = modes[diffModeIdx];
+
+ const onClick = () => {
+ setDiffModeIdx((diffModeIdx + 1) % modes.length);
+ };
+ let pillText = '';
+ let pillColor: TagColorName = 'moon';
+ if (mode === 'absolute') {
+ const sign = isIncrease ? '+' : '';
+ pillColor = isIncrease ? 'green' : 'red';
+ pillText = `${sign}${difference.toLocaleString()}`;
+ } else if (mode === 'percentage') {
+ const percentage =
+ compareValue !== 0 ? (difference / compareValue) * 100 : NaN;
+ const percentageStr =
+ percentage > 100
+ ? Math.trunc(percentage).toLocaleString()
+ : percentage.toFixed(2);
+ pillColor = isIncrease ? 'green' : 'red';
+ pillText = `${percentageStr}%`;
+ } else if (mode === 'ratio') {
+ const ratio =
+ compareValue !== 0
+ ? `${(value / compareValue).toFixed(1)}x`
+ : 'undefined';
+ pillColor = 'green';
+ pillText = ratio;
+ }
+
+ return (
+
+
+
+ }
+ content="Click to change comparison mode"
+ />
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePage.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePage.tsx
new file mode 100644
index 000000000000..50c6c35ef143
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePage.tsx
@@ -0,0 +1,70 @@
+/**
+ * Dispatch to call or object specific comparison based on query parameters.
+ */
+
+import React from 'react';
+import {useHistory} from 'react-router-dom';
+
+import {Empty} from '../pages/common/Empty';
+import {
+ getParamArray,
+ queryGetBoolean,
+ queryGetDict,
+ queryGetString,
+} from '../urlQueryUtil';
+import {ComparePageCalls} from './ComparePageCalls';
+import {ComparePageObjects} from './ComparePageObjects';
+
+type ComparePageProps = {
+ entity: string;
+ project: string;
+};
+
+export const ComparePage = ({entity, project}: ComparePageProps) => {
+ const history = useHistory();
+ const d = queryGetDict(history);
+ const mode =
+ queryGetString(history, 'mode') === 'unified' ? 'unified' : 'parallel';
+ const baselineEnabled = queryGetBoolean(history, 'baseline', false);
+ const onlyChanged = queryGetBoolean(history, 'changed', false);
+ const callIds = getParamArray(d, 'call');
+
+ if (callIds.length) {
+ return (
+
+ );
+ }
+
+ const objs = getParamArray(d, 'obj');
+ if (objs.length) {
+ return (
+
+ );
+ }
+
+ // TODO: Link to docs if we make them
+ // Currently nothing links to this state but query parameters
+ // are sometimes dropped during dev reloading.
+ return (
+
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageCalls.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageCalls.tsx
new file mode 100644
index 000000000000..806dcd6dc579
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageCalls.tsx
@@ -0,0 +1,52 @@
+/**
+ * Handle loading call data for generic object comparison.
+ */
+import _ from 'lodash';
+import React from 'react';
+
+import {LoadingDots} from '../../../../LoadingDots';
+import {useWFHooks} from '../pages/wfReactInterface/context';
+import {ComparePageObjectsLoaded} from './ComparePageObjectsLoaded';
+import {Mode} from './types';
+
+type ComparePageCallsProps = {
+ entity: string;
+ project: string;
+ callIds: string[];
+ mode: Mode;
+ baselineEnabled: boolean;
+ onlyChanged: boolean;
+};
+
+export const ComparePageCalls = ({
+ entity,
+ project,
+ callIds,
+ mode,
+ baselineEnabled,
+ onlyChanged,
+}: ComparePageCallsProps) => {
+ const {useCalls} = useWFHooks();
+ const calls = useCalls(entity, project, {callIds});
+ if (calls.loading) {
+ return ;
+ }
+
+ // The calls don't come back sorted in the same order as the callIds we provided
+ const resultCalls = calls.result ?? [];
+ const resultCallsIndex = _.keyBy(resultCalls, 'callId');
+ const traceCalls = callIds
+ .map(id => resultCallsIndex[id]?.traceCall)
+ .filter((c): c is NonNullable => c != null);
+
+ return (
+
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjects.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjects.tsx
new file mode 100644
index 000000000000..0b4ea75e8966
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjects.tsx
@@ -0,0 +1,39 @@
+/**
+ * Handle loading object data.
+ */
+import React from 'react';
+
+import {LoadingDots} from '../../../../LoadingDots';
+import {ComparePageObjectsLoaded} from './ComparePageObjectsLoaded';
+import {useObjectVersions} from './hooks';
+import {Mode} from './types';
+
+type ComparePageObjectsProps = {
+ entity: string;
+ project: string;
+ objectIds: string[];
+ mode: Mode;
+ baselineEnabled: boolean;
+ onlyChanged: boolean;
+};
+
+export const ComparePageObjects = (props: ComparePageObjectsProps) => {
+ const {entity, project, objectIds} = props;
+ const {loading, objectVersions, lastVersionIndices} = useObjectVersions(
+ entity,
+ project,
+ objectIds
+ );
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+ );
+};
diff --git a/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjectsLoaded.tsx b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjectsLoaded.tsx
new file mode 100644
index 000000000000..3b3548f09969
--- /dev/null
+++ b/weave-js/src/components/PagePanelComponents/Home/Browse3/compare/ComparePageObjectsLoaded.tsx
@@ -0,0 +1,406 @@
+/**
+ * This is the main layout of the compare page.
+ * It also handles the logic around ref expansion.
+ */
+import {GridRowId} from '@mui/x-data-grid-pro';
+import {Switch} from '@wandb/weave/components';
+import * as DropdownMenu from '@wandb/weave/components/DropdownMenu';
+import _ from 'lodash';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {useHistory} from 'react-router-dom';
+
+import {Button} from '../../../../Button';
+import {Icon} from '../../../../Icon';
+import {TailwindContents} from '../../../../Tailwind';
+import {isWeaveRef} from '../filters/common';
+import {mapObject, TraverseContext} from '../pages/CallPage/traverse';
+import {SimplePageLayout} from '../pages/common/SimplePageLayout';
+import {useWFHooks} from '../pages/wfReactInterface/context';
+import {
+ queryGetString,
+ querySetArray,
+ querySetString,
+ queryToggleBoolean,
+} from '../urlQueryUtil';
+import {computeDiff, mergeObjects} from './compare';
+import {CompareGrid, MAX_OBJECT_COLS} from './CompareGrid';
+import {isSequentialVersions, parseSpecifier} from './hooks';
+import {getExpandableRefs, RefValues, RESOLVED_REF_KEY} from './refUtil';
+import {ShoppingCart} from './ShoppingCart';
+import {ComparableObject, Mode} from './types';
+
+type ComparePageObjectsLoadedProps = {
+ objectType: 'object' | 'call';
+ objectIds: string[];
+ mode: Mode;
+ baselineEnabled: boolean;
+ onlyChanged: boolean;
+ objects: ComparableObject[];
+ lastVersionIndices?: Record;
+};
+
+// TODO: These are always going to be different so not useful to flag as such.
+// But maybe seeing value for things like versionHash is still useful?
+const UNINTERESTING_PATHS_CALL = ['id'];
+const UNINTERESTING_PATHS_OBJ = [
+ 'baseObjectClass',
+ 'entity',
+ 'path',
+ 'objectId',
+ 'project',
+ 'scheme',
+ 'versionHash',
+ 'versionIndex',
+ 'weaveKind',
+];
+
+const IconPlaceholder = () => ;
+
+export const ComparePageObjectsLoaded = ({
+ objectType,
+ objectIds,
+ mode,
+ baselineEnabled,
+ onlyChanged,
+ objects,
+ lastVersionIndices,
+}: ComparePageObjectsLoadedProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [expandedIds, setExpandedIds] = useState([]);
+
+ // Sequential object version navigation
+ // TODO: Handle deleted versions
+ // TODO: Disable when at last version to avoid crash
+ const param = objectType === 'call' ? 'call' : 'obj';
+ let isSequential = false;
+ const first = parseSpecifier(objectIds[0]);
+ const last = parseSpecifier(objectIds[objectIds.length - 1]);
+ let onPrev: (() => void) | undefined;
+ let onNext: (() => void) | undefined;
+ if (objectType === 'object') {
+ isSequential = isSequentialVersions(objectIds);
+ const onNav = (delta: number) => {
+ const specifiers = [];
+ for (let i = 0; i < objectIds.length; i++) {
+ specifiers.push(`${first.name}:v${first.version! + i + delta}`);
+ }
+ querySetArray(history, param, specifiers);
+ };
+ onPrev = () => onNav(-1);
+ onNext = () => onNav(1);
+ }
+
+ const history = useHistory();
+
+ // TODO: For efficiency we might move this logic up to the
+ // initial data fetching layer.
+ const selected = queryGetString(history, 'sel');
+ let filteredObjectIds = objectIds;
+ let filteredObjects = objects;
+ if (selected) {
+ const idx = objectIds.indexOf(selected);
+ if (idx === 0) {
+ filteredObjectIds = [objectIds[0], objectIds[1]];
+ filteredObjects = [objects[0], objects[1]];
+ } else if (idx > 0) {
+ if (baselineEnabled) {
+ filteredObjectIds = [objectIds[0], objectIds[idx]];
+ filteredObjects = [objects[0], objects[idx]];
+ } else {
+ filteredObjectIds = [objectIds[idx - 1], objectIds[idx]];
+ filteredObjects = [objects[idx - 1], objects[idx]];
+ }
+ }
+ }
+
+ const {useRefsData} = useWFHooks();
+
+ // `resolvedData` holds ref-resolved data.
+ const [resolvedData, setResolvedData] = useState(objects);
+
+ // `dataRefs` are the refs contained in the data, filtered to only include expandable refs.
+ const dataRefs = useMemo(() => getExpandableRefs(objects), [objects]);
+
+ // Expanded refs are the explicit set of refs that have been expanded by the user. Note that
+ // this might contain nested refs not in the `dataRefs` set. The keys are object paths at which the refs were expanded
+ // and the values are the corresponding ref strings.
+ const [expandedRefs, setExpandedRefs] = useState<{[path: string]: string[]}>(
+ {}
+ );
+
+ // `addExpandedRefs` is a function that can be used to add expanded refs to the `expandedRefs` state.
+ const addExpandedRefs = useCallback((path: string, refsToAdd: string[]) => {
+ setExpandedRefs(eRefs => ({...eRefs, [path]: refsToAdd}));
+ }, []);
+
+ // `refs` is the union of `dataRefs` and the refs in `expandedRefs`.
+ const refs = useMemo(() => {
+ return Array.from(
+ new Set([...dataRefs, ...Object.values(expandedRefs).flat()])
+ );
+ }, [dataRefs, expandedRefs]);
+
+ // finally, we get the ref data for all refs. This function is highly memoized and
+ // cached. Therefore, we only ever make network calls for new refs in the list.
+ const refsData = useRefsData(refs);
+
+ // This effect is responsible for resolving the refs in the data. It iteratively
+ // replaces refs with their resolved values. It also adds a `_ref` key to the resolved
+ // value to indicate the original ref URI. It is ultimately responsible for setting
+ // `resolvedData`.
+ useEffect(() => {
+ if (refsData.loading) {
+ return;
+ }
+ const resolvedRefData = refsData.result;
+
+ const refValues: RefValues = {};
+ for (const [r, v] of _.zip(refs, resolvedRefData)) {
+ if (!r || !v) {
+ // Shouldn't be possible
+ continue;
+ }
+ let val = r;
+ if (v == null) {
+ console.error('Error resolving ref', r);
+ } else {
+ val = v;
+ if (typeof val === 'object' && val !== null) {
+ val = {
+ ...v,
+ [RESOLVED_REF_KEY]: r,
+ };
+ } else {
+ // This makes it so that runs pointing to primitives can still be expanded in the table.
+ val = {
+ '': v,
+ [RESOLVED_REF_KEY]: r,
+ };
+ }
+ }
+ refValues[r] = val;
+ }
+ let resolved = objects;
+ let dirty = true;
+ const mapper = (context: TraverseContext) => {
+ if (
+ isWeaveRef(context.value) &&
+ refValues[context.value] != null &&
+ // Don't expand _ref keys
+ context.path.tail() !== RESOLVED_REF_KEY
+ ) {
+ dirty = true;
+ return refValues[context.value];
+ }
+ return _.clone(context.value);
+ };
+ while (dirty) {
+ dirty = false;
+ resolved = resolved.map(o => mapObject(o, mapper));
+ }
+ setResolvedData(resolved);
+ }, [objects, refs, refsData.loading, refsData.result]);
+
+ const merged = mergeObjects(objectIds, resolvedData);
+
+ // Hide rows that are uninteresting for diff
+ const uninterestingPaths =
+ objectType === 'call' ? UNINTERESTING_PATHS_CALL : UNINTERESTING_PATHS_OBJ;
+ const filtered = merged.filter(
+ row => !uninterestingPaths.includes(row.path.toString())
+ );
+ const diffed = computeDiff(objectIds, filtered, false);
+
+ // Determine if objects are all the same type. If so, use that
+ // term instead of the generic "object" in title
+ let title = 'Compare calls';
+ if (objectType === 'object') {
+ const baseClasses = objects.map(v => v.baseObjectClass);
+ const allSameType =
+ baseClasses[0] && baseClasses.every(c => c === baseClasses[0]);
+ const baseType = allSameType
+ ? baseClasses[0]?.toLocaleLowerCase()
+ : 'object';
+ title = `Compare ${baseType}s`;
+ }
+
+ const hasModes = objectIds.length === 2 || selected;
+ const checkedMode = hasModes ? mode : 'parallel';
+
+ const cartItems = objects.map(v => {
+ if (objectType === 'call') {
+ return {
+ key: 'call',
+ value: v.id,
+ label: v.id.slice(-4),
+ };
+ }
+ return {
+ key: 'obj',
+ value: `${v.objectId}:v${v.versionIndex}`,
+ };
+ });
+
+ const onSetMode = (newMode: Mode) => {
+ querySetString(history, 'mode', newMode);
+ };
+
+ const setOnlyChanged = () => {
+ queryToggleBoolean(history, 'changed', false);
+ };
+
+ const onSetBaseline = (value: boolean) => {
+ const {search} = history.location;
+ const params = new URLSearchParams(search);
+ if (value) {
+ params.set('baseline', '1');
+ } else {
+ params.delete('baseline');
+ }
+ history.replace({
+ search: params.toString(),
+ });
+ };
+
+ const tooManyCols = objectIds.length > MAX_OBJECT_COLS && !selected;
+
+ return (
+
+