From 3e53d7e071ac69e979b52478c6444389fbbf02fe Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 15 May 2026 13:26:46 +0300 Subject: [PATCH] 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 --- .../ui/src/components/icons/FusionIcon.tsx | 31 +++ .../src/components/multirun/AgentSelector.tsx | 5 +- .../components/multirun/ModelMultiSelect.tsx | 69 ++++- .../multirun/MultiRunFusionDialog.tsx | 259 ++++++++++++++++++ .../magic-prompts/MagicPromptsPage.tsx | 8 + .../magic-prompts/MagicPromptsSidebar.tsx | 1 + .../session/sidebar/SessionNodeItem.tsx | 18 ++ packages/ui/src/components/ui/select.tsx | 6 +- .../ui/src/lib/i18n/messages/en.settings.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 11 + .../ui/src/lib/i18n/messages/es.settings.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 11 + .../ui/src/lib/i18n/messages/ko.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 11 + .../ui/src/lib/i18n/messages/pl.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 11 + .../src/lib/i18n/messages/pt-BR.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 11 + .../ui/src/lib/i18n/messages/uk.settings.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 11 + .../src/lib/i18n/messages/zh-CN.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 11 + packages/ui/src/lib/magicPrompts.ts | 24 +- packages/ui/src/lib/multirun/title.ts | 42 +++ 24 files changed, 548 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/components/icons/FusionIcon.tsx create mode 100644 packages/ui/src/components/multirun/MultiRunFusionDialog.tsx create mode 100644 packages/ui/src/lib/multirun/title.ts diff --git a/packages/ui/src/components/icons/FusionIcon.tsx b/packages/ui/src/components/icons/FusionIcon.tsx new file mode 100644 index 00000000..b8cc4e70 --- /dev/null +++ b/packages/ui/src/components/icons/FusionIcon.tsx @@ -0,0 +1,31 @@ +import type { SVGProps } from 'react'; + +export function FusionIcon(props: SVGProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/multirun/AgentSelector.tsx b/packages/ui/src/components/multirun/AgentSelector.tsx index 3a81a2cc..a538a268 100644 --- a/packages/ui/src/components/multirun/AgentSelector.tsx +++ b/packages/ui/src/components/multirun/AgentSelector.tsx @@ -22,6 +22,8 @@ export interface AgentSelectorProps { disabled?: boolean; /** ID for accessibility */ id?: string; + /** Portal menu to body instead of nearest dialog. */ + portalToBody?: boolean; } /** @@ -34,6 +36,7 @@ export const AgentSelector: React.FC = ({ className, disabled, id, + portalToBody, }) => { const { t } = useI18n(); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); @@ -95,7 +98,7 @@ export const AgentSelector: React.FC = ({ > - + {selectableAgents.length > 0 && ( {selectableAgents.map((agent) => ( diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index 0978c65d..a668c3e5 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -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 = ({ 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 = ({ const dropdownRef = React.useRef(null); const triggerRef = React.useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({
{/* Add model button (dropdown trigger) */} -
+
@@ -398,12 +435,22 @@ export const ModelMultiSelect: React.FC = ({ 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 = ({ return (
{ + const title = source.session.title?.trim() || source.session.id; + return `\n\n--- RESULT ${index + 1}: ${title} ---\n${text.trim()}\n--- END RESULT ${index + 1} ---`; +}; + +const getSessionProjectDirectory = (sessionId: string, directory: string | null): string | null => { + const metadata = useSessionUIStore.getState().getWorktreeMetadata(sessionId); + return metadata?.projectDirectory ?? directory; +}; + +const getLastAssistantText = async (source: FusionSource): Promise => { + const directory = source.directory ?? undefined; + const messages = getSyncMessages(source.session.id, directory); + + if (messages.length === 0 && source.directory) { + const result = await opencodeClient.withDirectory(source.directory, () => + opencodeClient.getSdkClient().session.messages({ + sessionID: source.session.id, + directory: source.directory ?? undefined, + limit: 50, + }) + ); + const records = result.data ?? []; + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index] as { info?: { role?: string }; parts?: unknown[] }; + if (record.info?.role !== 'assistant') continue; + return flattenAssistantTextParts((record.parts ?? []) as Parameters[0]).trim(); + } + return ''; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== 'assistant') continue; + return flattenAssistantTextParts(getSyncParts(message.id, directory)).trim(); + } + + return ''; +}; + +export function MultiRunFusionDialog({ + session, + open, + onOpenChange, +}: { + session: Session; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const liveSessions = useAllLiveSessions(); + const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); + const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); + const providers = useConfigStore((state) => state.providers); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const [providerID, setProviderID] = React.useState(currentProviderId ?? ''); + const [modelID, setModelID] = React.useState(currentModelId ?? ''); + const [selectedModelSelection, setSelectedModelSelection] = React.useState(() => ( + currentProviderId && currentModelId + ? [{ providerID: currentProviderId, modelID: currentModelId, instanceId: generateInstanceId() }] + : [] + )); + const [variant, setVariant] = React.useState(''); + const [agent, setAgent] = React.useState(currentAgentName ?? ''); + const [sources, setSources] = React.useState([]); + const [isStarting, setIsStarting] = React.useState(false); + + const parsed = React.useMemo(() => parseMultiRunSessionTitle(session.title), [session.title]); + const allSessions = React.useMemo(() => { + const byId = new Map(); + for (const candidate of liveSessions) byId.set(candidate.id, candidate); + for (const candidate of activeSessions) byId.set(candidate.id, candidate); + for (const candidate of archivedSessions) byId.set(candidate.id, candidate); + if (session.id) byId.set(session.id, session); + return Array.from(byId.values()); + }, [activeSessions, archivedSessions, liveSessions, session]); + + React.useEffect(() => { + if (!open || !parsed) return; + + const currentDirectory = useSessionUIStore.getState().getDirectoryForSession(session.id); + const currentProjectDirectory = getSessionProjectDirectory(session.id, currentDirectory); + const nextSources = allSessions + .map((candidate): FusionSource | null => { + const candidateParsed = parseMultiRunSessionTitle(candidate.title); + if (!candidateParsed || candidateParsed.groupSlug !== parsed.groupSlug || candidateParsed.fusion) return null; + const directory = useSessionUIStore.getState().getDirectoryForSession(candidate.id) + ?? resolveGlobalSessionDirectory(candidate); + const projectDirectory = getSessionProjectDirectory(candidate.id, directory); + if (currentProjectDirectory && projectDirectory && currentProjectDirectory !== projectDirectory) return null; + return { session: candidate, directory, projectDirectory }; + }) + .filter((source): source is FusionSource => source !== null) + .sort((a, b) => (a.session.time?.created ?? 0) - (b.session.time?.created ?? 0)); + + setSources(nextSources); + }, [allSessions, open, parsed, session.id]); + + const selectedProvider = providers.find((provider) => provider.id === providerID); + const selectedProviderModel = selectedProvider?.models.find((model) => model.id === modelID) as { variants?: Record } | undefined; + const variantKeys = selectedProviderModel?.variants ? Object.keys(selectedProviderModel.variants) : []; + const canStart = Boolean(parsed && providerID && modelID && sources.length > 0 && !isStarting); + + const handleModelSelect = React.useCallback((model: ModelSelectionWithId) => { + setSelectedModelSelection([model]); + setProviderID(model.providerID); + setModelID(model.modelID); + setVariant(''); + }, []); + + const selectedModelLabel = selectedModelSelection[0]?.displayName || selectedModelSelection[0]?.modelID || t('multirun.fusion.model.placeholder'); + + const handleStart = async () => { + if (!parsed || !providerID || !modelID) return; + setIsStarting(true); + try { + const sourceTexts = await Promise.all(sources.map((source) => getLastAssistantText(source))); + const usableSources = sources + .map((source, index) => ({ source, text: sourceTexts[index] ?? '' })) + .filter((item) => item.text.trim().length > 0); + + if (usableSources.length === 0) { + toast.error(t('multirun.fusion.toast.noOutputs')); + return; + } + + const directory = sources[0]?.projectDirectory ?? sources[0]?.directory ?? null; + const fusionTitle = getFusionSessionTitle(parsed.groupSlug, providerID, modelID); + const [visiblePrompt, instructionsPrompt] = await Promise.all([ + renderMagicPrompt('session.fusion.visible'), + renderMagicPrompt('session.fusion.instructions'), + ]); + const fusionSession = await useSessionUIStore.getState().createSession(fusionTitle, directory, null); + if (!fusionSession) throw new Error('Failed to create fusion session'); + + useSessionUIStore.getState().setCurrentSession(fusionSession.id, directory); + onOpenChange(false); + + await opencodeClient.withDirectory(directory ?? opencodeClient.getDirectory(), () => + opencodeClient.sendMessage({ + id: fusionSession.id, + providerID, + modelID, + variant: variant || undefined, + agent: agent || undefined, + text: visiblePrompt, + additionalParts: [ + { text: instructionsPrompt, synthetic: true }, + ...usableSources.map((item, index) => ({ text: buildSourcePart(item.source, item.text, index), synthetic: true })), + { text: '\n\n--- FUSION INPUTS END ---\nNow write the final fused answer.', synthetic: true }, + ], + }) + ); + } catch (error) { + console.error('[MultiRunFusion] Failed to start fusion', error); + toast.error(t('multirun.fusion.toast.failed')); + } finally { + setIsStarting(false); + } + }; + + return ( + + + + {t('multirun.fusion.title')} + {t('multirun.fusion.description')} + + +
+
+ handleModelSelect(model)} + onRemove={() => { + setSelectedModelSelection([]); + setProviderID(''); + setModelID(''); + setVariant(''); + }} + maxModels={1} + addButtonLabel={selectedModelLabel} + showChips={false} + addButtonClassName="h-8 w-fit max-w-[min(28rem,calc(100vw-8rem))] justify-start rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] px-3 py-1.5" + dropdownSide="bottom" + dropdownClassName="w-[min(28rem,calc(100vw-8rem))]" + triggerIcon={providerID ? : undefined} + /> +
+ + {variantKeys.length > 0 ? ( + + ) : null} + + +
+ +
+
{t('multirun.fusion.sources.label', { count: sources.length })}
+
+ {sources.map((source) => ( +
+ + {source.session.title || source.session.id} + +
+ ))} +
+
+ + + + + +
+
+ ); +} diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 9e66ce24..9de644c7 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -140,6 +140,14 @@ const PROMPT_PAGE_MAP: Record = { { id: 'session.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, ], }, + 'session.fusion': { + titleKey: 'settings.magicPrompts.page.group.sessionFusion.title', + descriptionKey: 'settings.magicPrompts.page.group.sessionFusion.description', + blocks: [ + { id: 'session.fusion.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' }, + { id: 'session.fusion.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, + ], + }, }; const hasOwn = (input: Record, key: string) => Object.prototype.hasOwnProperty.call(input, key); diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx index cf1a9774..486c9863 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx @@ -47,6 +47,7 @@ export const MagicPromptsSidebar: React.FC = ({ onItem items: [ { id: 'session.summary', titleKey: 'settings.magicPrompts.sidebar.item.sessionSummary' }, { id: 'session.review', titleKey: 'settings.magicPrompts.sidebar.item.sessionWorkspaceReview' }, + { id: 'session.fusion', titleKey: 'settings.magicPrompts.sidebar.item.sessionFusion' }, ], }, ] as const; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 292786ad..cb611059 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -29,6 +29,9 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useI18n } from '@/lib/i18n'; +import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; +import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; +import { FusionIcon } from '@/components/icons/FusionIcon'; type Folder = { id: string; name: string; sessionIds: string[] }; @@ -319,6 +322,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; + const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]); + const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false); const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]); @@ -670,6 +675,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { {t('sessions.sidebar.session.menu.exportMarkdown')} + {isMultiRunLikeSession ? ( + setFusionDialogOpen(true)} className="[&>svg]:mr-1"> + + {t('sessions.sidebar.session.menu.runFusion')} + + ) : null} {sessionDirectory && !archivedBucket ? (() => { const scopeFolders = getFoldersForScope(sessionDirectory); @@ -980,6 +991,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { + {isMultiRunLikeSession ? ( + + ) : null} ); } diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index fb8eec4e..a6b7e92b 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -155,6 +155,7 @@ function SelectTrigger({ type SelectContentExtra = { position?: "popper" | "item-aligned"; fitContent?: boolean; + portalToBody?: boolean; sideOffset?: number; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; @@ -165,6 +166,7 @@ function SelectContent({ children, position = "popper", fitContent = false, + portalToBody = false, sideOffset, side, align, @@ -175,13 +177,13 @@ function SelectContent({ const portalContainer = portalContext?.portalContainer ?? null; return ( - +