diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index fc53e605..eda9f26e 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -15,6 +15,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils'; import { FadeInOnReveal } from './FadeInOnReveal'; import { Button } from '@/components/ui/button'; import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog'; +import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; @@ -1031,6 +1032,8 @@ const AssistantMessageBody = React.memo(({ const effectiveDirectory = useEffectiveDirectory(); const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false); + const [isForkDialogOpen, setIsForkDialogOpen] = React.useState(false); + const [isForkSubmitting, setIsForkSubmitting] = React.useState(false); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks); const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks); @@ -1179,10 +1182,26 @@ const AssistantMessageBody = React.memo(({ (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); + if (!createSessionFromAssistantMessage || !assistantPlanText.trim()) { + return; + } + setIsForkDialogOpen(true); + }, + [createSessionFromAssistantMessage, assistantPlanText] + ); + + const handleConfirmFork = React.useCallback( + async (execution: ForkSessionExecution) => { if (!createSessionFromAssistantMessage) { return; } - void createSessionFromAssistantMessage(messageId); + setIsForkSubmitting(true); + try { + await createSessionFromAssistantMessage(messageId, execution); + setIsForkDialogOpen(false); + } finally { + setIsForkSubmitting(false); + } }, [createSessionFromAssistantMessage, messageId] ); @@ -1848,6 +1867,15 @@ const AssistantMessageBody = React.memo(({ saving={isSavingPlan} onSave={handleConfirmSaveAsPlan} /> + ) : null} + {isForkDialogOpen ? ( + ) : null}
= ({ ) : ( <> {providerId ? : } - {triggerLabel} + {triggerLabel} )} diff --git a/packages/ui/src/components/sections/commands/AgentSelector.tsx b/packages/ui/src/components/sections/commands/AgentSelector.tsx index 93cf7a7e..0c26c7e2 100644 --- a/packages/ui/src/components/sections/commands/AgentSelector.tsx +++ b/packages/ui/src/components/sections/commands/AgentSelector.tsx @@ -21,6 +21,7 @@ interface AgentSelectorProps { onChange: (agentName: string) => void; className?: string; filter?: (agent: Agent) => boolean; + dropdownPortalToBody?: boolean; } export const AgentSelector: React.FC = ({ @@ -28,6 +29,7 @@ export const AgentSelector: React.FC = ({ onChange, className, filter, + dropdownPortalToBody = false, }) => { const { t } = useI18n(); const { isReady, isUnavailable } = useOpenCodeReadiness(); @@ -176,7 +178,7 @@ export const AgentSelector: React.FC = ({
- + handleAgentChange('')} diff --git a/packages/ui/src/components/session/ForkSessionDialog.tsx b/packages/ui/src/components/session/ForkSessionDialog.tsx new file mode 100644 index 00000000..4ac32d07 --- /dev/null +++ b/packages/ui/src/components/session/ForkSessionDialog.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { AgentSelector } from '@/components/sections/commands/AgentSelector'; +import { ThinkingPill } from '@/components/session/ThinkingPill'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useAgentsStore } from '@/stores/useAgentsStore'; +import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; +import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS } from '@/lib/messages/executionMeta'; +import { useI18n } from '@/lib/i18n'; + +export type ForkSessionExecution = { + providerID: string; + modelID: string; + variant: string; + agent: string; + instructions: string; +}; + +type ForkSessionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + projectDirectory: string | null; + submitting?: boolean; + onConfirm: (execution: ForkSessionExecution) => Promise | void; +}; + +export function ForkSessionDialog(props: ForkSessionDialogProps) { + const { t } = useI18n(); + const { open, onOpenChange, projectDirectory, submitting = false, onConfirm } = props; + + const loadProviders = useConfigStore((state) => state.loadProviders); + const loadConfigAgents = useConfigStore((state) => state.loadAgents); + const loadAgentsStoreAgents = useAgentsStore((state) => state.loadAgents); + const providers = useConfigStore((state) => state.providers); + const currentProviderID = useConfigStore((state) => state.currentProviderId); + const currentModelID = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant || ''); + const currentAgentName = useConfigStore((state) => state.currentAgentName || ''); + + const [providerID, setProviderID] = React.useState(currentProviderID); + const [modelID, setModelID] = React.useState(currentModelID); + const [variant, setVariant] = React.useState(currentVariant); + const [agent, setAgent] = React.useState(currentAgentName); + const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); + + React.useEffect(() => { + if (!open) return; + void loadProviders({ directory: projectDirectory }); + void loadConfigAgents({ directory: projectDirectory }); + void loadAgentsStoreAgents(); + }, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]); + + // Reset only when the dialog transitions to open. Reading the store snapshot + // here (instead of subscribing) avoids clobbering in-progress user edits when + // the config store refreshes in the background while the dialog is open. + React.useEffect(() => { + if (!open) return; + const config = useConfigStore.getState(); + setProviderID(config.currentProviderId); + setModelID(config.currentModelId); + setVariant(config.currentVariant || ''); + setAgent(config.currentAgentName || ''); + setInstructions(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); + }, [open]); + + React.useEffect(() => { + if (!open || providers.length === 0) return; + + const provider = providers.find((item) => item.id === providerID) ?? providers[0]; + const models = Array.isArray(provider?.models) ? provider.models : []; + const hasModel = models.some((item) => item.id === modelID); + const fallbackModelID = models[0]?.id ?? ''; + + if (provider?.id === providerID && hasModel) return; + + setProviderID(provider?.id ?? ''); + setModelID(hasModel ? modelID : fallbackModelID); + setVariant(''); + }, [open, providers, providerID, modelID]); + + const agentFilter = React.useCallback((candidate: { mode?: string }) => isPrimaryMode(candidate.mode), []); + + const variantOptions = React.useMemo(() => { + const provider = providers.find((item) => item.id === providerID); + const model = provider?.models?.find((item) => item.id === modelID) as { variants?: Record } | undefined; + return model?.variants ? Object.keys(model.variants) : []; + }, [providers, providerID, modelID]); + + const hasVariantOptions = variantOptions.length > 0; + + React.useEffect(() => { + if (hasVariantOptions || !variant) return; + setVariant(''); + }, [hasVariantOptions, variant]); + + const canConfirm = + providerID.trim().length > 0 && modelID.trim().length > 0 && instructions.trim().length > 0; + + const handleSubmit = React.useCallback(() => { + if (!canConfirm || submitting) return; + void onConfirm({ providerID, modelID, variant, agent, instructions }); + }, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions]); + + React.useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + handleSubmit(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, handleSubmit]); + + return ( + { if (!submitting) onOpenChange(nextOpen); }}> + + + {t('chat.messageBody.actions.startNewSession')} + + +
+
+ {t('chat.modelControls.model')} + { + setProviderID(nextProviderID); + setModelID(nextModelID); + setVariant(''); + }} + /> +
+
+ {t('sessions.scheduledTasks.editor.thinkingLevel.label')} + +
+
+ {t('sessions.scheduledTasks.editor.agent.label')} + +
+
+ {t('chat.messageBody.forkDialog.instructions.label')} +