From 6a88cd09ccb4a5a3358b3b1e700947481a457835 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 2 Jun 2026 12:53:30 +0300 Subject: [PATCH] feat: add dialog for "Start new session from this answer" (#1501) * feat: add dialog for "Start new session from this answer" Replace the one-click fork action on assistant messages with a dialog (ForkSessionDialog) that lets the user pick model, thinking level, and agent, plus edit the instructions sent to the new session. The instructions field is prefilled with the previous fixed fork prompt and is mandatory. The composed message is now fully visible (no synthetic preface): the user's instructions sit above a short fixed connective that opens the assistant content. createSessionFromAssistantMessage takes the chosen execution params instead of reading from config. Also fix TodoSendDialog visuals: narrower vertical layout, model trigger no longer stretches with centered text, and the agent/thinking dropdowns portal to body so opening them no longer nudges the dialog height. Extract the shared ThinkingPill into its own component. * fix: address review feedback on fork session dialog - Fix "bellow" -> "below" typo in the fork content preface (now user-visible since the message is no longer synthetic) - Reset ForkSessionDialog state only on open transition, reading the config store snapshot via getState() so background store refreshes can't discard in-progress instruction edits --- .../components/chat/message/MessageBody.tsx | 30 ++- .../sections/agents/ModelSelector.tsx | 2 +- .../sections/commands/AgentSelector.tsx | 4 +- .../components/session/ForkSessionDialog.tsx | 189 ++++++++++++++++++ .../src/components/session/ThinkingPill.tsx | 59 ++++++ .../src/components/session/TodoSendDialog.tsx | 66 +----- packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + packages/ui/src/lib/messages/executionMeta.ts | 22 ++ packages/ui/src/stores/types/sessionTypes.ts | 2 +- packages/ui/src/sync/session-ui-store.ts | 18 +- 17 files changed, 334 insertions(+), 74 deletions(-) create mode 100644 packages/ui/src/components/session/ForkSessionDialog.tsx create mode 100644 packages/ui/src/components/session/ThinkingPill.tsx 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')} +