feat: add fusion for multi-run sessions

Adds Run fusion for multi-run-like sessions
Combines sibling outputs into a new fusion session
Adds configurable Fusion prompts in Magic Prompts settings
This commit is contained in:
Bohdan Triapitsyn
2026-05-15 13:26:46 +03:00
parent 713bb6dfb1
commit 3e53d7e071
24 changed files with 548 additions and 13 deletions
@@ -100,6 +100,14 @@ export interface ModelMultiSelectProps {
maxModels?: number;
/** Optional className for add model trigger button */
addButtonClassName?: string;
/** Direction for the model picker popup. Multi-run launcher opens upward near the footer. */
dropdownSide?: 'top' | 'bottom';
/** Optional className for the picker popup. */
dropdownClassName?: string;
/** Optional className for the trigger/dropdown positioning container. */
containerClassName?: string;
/** Optional trigger icon override. */
triggerIcon?: React.ReactNode;
}
/**
@@ -115,6 +123,10 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
showChips = true,
maxModels,
addButtonClassName,
dropdownSide = 'top',
dropdownClassName,
containerClassName,
triggerIcon,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
@@ -128,7 +140,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
const dropdownRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const canAddModel = maxModels === undefined || selectedModels.length < maxModels;
const isSingleSelect = maxModels === 1;
const canAddModel = maxModels === undefined || selectedModels.length < maxModels || isSingleSelect;
// Count occurrences of each model for display purposes
const modelCounts = React.useMemo(() => {
@@ -208,12 +221,21 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0;
// Calculate available height: space above trigger within visible area
// Calculate available height: multi-run opens upward inside a scroller; fusion opens downward and may extend past the dialog.
React.useEffect(() => {
if (!isOpen || !triggerRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
if (dropdownSide === 'bottom') {
const viewportHeight = window.visualViewport?.height ?? document.documentElement.clientHeight ?? window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom - 16;
// availableHeight is only the scrollable model list; reserve room for search + keyboard hint chrome.
const listSpaceBelow = spaceBelow - 112;
setAvailableHeight(Math.max(160, Math.min(320, listSpaceBelow)));
return;
}
// Find the nearest dialog or overflow ancestor to constrain within
let container: HTMLElement | null = triggerRef.current.parentElement;
while (container) {
@@ -231,7 +253,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
const spaceAbove = triggerRect.top - topBound - 16;
// Cap: min 150, max 300
setAvailableHeight(Math.max(150, Math.min(300, spaceAbove)));
}, [isOpen]);
}, [dropdownSide, isOpen]);
// Focus search input when opened
React.useEffect(() => {
@@ -292,12 +314,27 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
type="button"
disabled={!canAddModel}
onClick={() => {
onAdd({
const nextModel = {
providerID,
modelID,
displayName: (model.name as string) || modelID,
instanceId: generateInstanceId(),
};
if (isSingleSelect && selectedModels.length > 0 && onUpdate) {
onUpdate(0, nextModel);
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
return;
}
onAdd({
...nextModel,
});
if (isSingleSelect) {
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
}
// Don't close dropdown - allow selecting multiple
}}
onMouseEnter={() => setSelectedIndex(flatIndex)}
@@ -333,7 +370,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5 items-center">
{/* Add model button (dropdown trigger) */}
<div className="relative" ref={dropdownRef}>
<div className={cn('relative', containerClassName)} ref={dropdownRef}>
<Button
ref={triggerRef}
type="button"
@@ -349,7 +386,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
setIsOpen(!isOpen);
}}
>
<Icon name="add" className="h-3.5 w-3.5 mr-1" />
{triggerIcon ?? <Icon name="add" className="h-3.5 w-3.5 mr-1" />}
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
</Button>
@@ -398,12 +435,22 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
e.stopPropagation();
const selectedItem = flatModelList[selectedIndex];
if (selectedItem && canAddModel) {
onAdd({
const nextModel = {
providerID: selectedItem.providerID,
modelID: selectedItem.modelID,
displayName: (selectedItem.model.name as string) || selectedItem.modelID,
instanceId: generateInstanceId(),
});
};
if (isSingleSelect && selectedModels.length > 0 && onUpdate) {
onUpdate(0, nextModel);
} else {
onAdd(nextModel);
}
if (isSingleSelect) {
setIsOpen(false);
setSearchQuery('');
setSelectedIndex(0);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
@@ -418,7 +465,11 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
return (
<div
className="absolute bottom-full left-0 mb-1 z-50 w-[min(380px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden rounded-xl border border-border/50 shadow-lg"
className={cn(
'absolute left-0 z-50 w-[min(420px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden rounded-xl border border-border/50 shadow-lg',
dropdownSide === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
dropdownClassName,
)}
style={{
background: 'linear-gradient(var(--surface-elevated),var(--surface-elevated)),linear-gradient(var(--surface-background),var(--surface-background))',
}}