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
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 12:53:30 +03:00
committed by GitHub
parent aefbdbc52f
commit 6a88cd09cc
17 changed files with 334 additions and 74 deletions
@@ -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<HTMLButtonElement>) => {
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 ? (
<ForkSessionDialog
open={isForkDialogOpen}
onOpenChange={setIsForkDialogOpen}
projectDirectory={effectiveDirectory ?? null}
submitting={isForkSubmitting}
onConfirm={handleConfirmFork}
/>
) : null}
<div>
<div
@@ -170,7 +170,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
) : (
<>
{providerId ? <ProviderLogo providerId={providerId} className="h-3.5 w-3.5 flex-shrink-0" /> : <Icon name="pencil-ai" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />}
<span className="typography-ui-label min-w-0 truncate font-normal text-foreground">{triggerLabel}</span>
<span className="typography-ui-label min-w-0 flex-1 truncate text-left font-normal text-foreground">{triggerLabel}</span>
</>
)}
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
@@ -21,6 +21,7 @@ interface AgentSelectorProps {
onChange: (agentName: string) => void;
className?: string;
filter?: (agent: Agent) => boolean;
dropdownPortalToBody?: boolean;
}
export const AgentSelector: React.FC<AgentSelectorProps> = ({
@@ -28,6 +29,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
onChange,
className,
filter,
dropdownPortalToBody = false,
}) => {
const { t } = useI18n();
const { isReady, isUnavailable } = useOpenCodeReadiness();
@@ -176,7 +178,7 @@ export const AgentSelector: React.FC<AgentSelectorProps> = ({
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]">
<DropdownMenuContent className="max-w-[300px]" portalToBody={dropdownPortalToBody}>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange('')}
@@ -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> | 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<string, unknown> } | 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 (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!submitting) onOpenChange(nextOpen); }}>
<DialogContent className="max-w-md overflow-visible">
<DialogHeader>
<DialogTitle>{t('chat.messageBody.actions.startNewSession')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-meta font-medium text-muted-foreground">{t('chat.modelControls.model')}</span>
<ModelSelector
providerId={providerID}
modelId={modelID}
className="max-w-[320px] justify-between"
dropdownPortalToBody
onChange={(nextProviderID, nextModelID) => {
setProviderID(nextProviderID);
setModelID(nextModelID);
setVariant('');
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-medium text-muted-foreground">{t('sessions.scheduledTasks.editor.thinkingLevel.label')}</span>
<ThinkingPill
value={variant}
options={variantOptions}
disabled={!hasVariantOptions}
onChange={setVariant}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-medium text-muted-foreground">{t('sessions.scheduledTasks.editor.agent.label')}</span>
<AgentSelector
agentName={agent}
filter={agentFilter}
dropdownPortalToBody
onChange={setAgent}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="typography-meta font-medium text-muted-foreground">{t('chat.messageBody.forkDialog.instructions.label')}</span>
<Textarea
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
placeholder={t('chat.messageBody.forkDialog.instructions.placeholder')}
hasError={instructions.trim().length === 0}
disabled={submitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')}
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}>
{submitting
? t('rightSidebar.contextNotesTodo.sendDialog.actions.sending')
: t('rightSidebar.contextNotesTodo.sendDialog.actions.send')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,59 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type ThinkingPillProps = {
value: string;
options: string[];
disabled?: boolean;
onChange: (value: string) => void;
};
export const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps) => {
const { t } = useI18n();
const label = value || t('rightSidebar.contextNotesTodo.sendDialog.variant.default');
const trigger = (
<div
className={cn(
'flex h-6 w-fit items-center gap-1.5 rounded-lg border border-border/20 bg-interactive-selection/20 px-2',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-interactive-hover/30',
)}
>
<span className="typography-micro whitespace-nowrap font-medium capitalize">{label}</span>
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</div>
);
if (disabled) return trigger;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-w-[220px]" portalToBody>
<DropdownMenuItem className="typography-meta" onSelect={() => onChange('')}>
<span className={cn('font-medium', !value && 'text-primary')}>
{t('rightSidebar.contextNotesTodo.sendDialog.variant.default')}
</span>
</DropdownMenuItem>
{options.map((option) => (
<DropdownMenuItem
key={option}
className="typography-meta"
onSelect={() => onChange(option)}
>
<span className={cn('font-medium capitalize', value === option && 'text-primary')}>
{option}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -6,19 +6,12 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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 { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type TodoSendTarget = 'session' | 'worktree';
@@ -51,56 +44,6 @@ const getInitialExecution = (params: {
agent: params.agent,
});
type ThinkingPillProps = {
value: string;
options: string[];
disabled?: boolean;
onChange: (value: string) => void;
};
const ThinkingPill = ({ value, options, disabled, onChange }: ThinkingPillProps) => {
const { t } = useI18n();
const label = value || t('rightSidebar.contextNotesTodo.sendDialog.variant.default');
const trigger = (
<div
className={cn(
'flex h-6 w-fit items-center gap-1.5 rounded-lg border border-border/20 bg-interactive-selection/20 px-2',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-interactive-hover/30',
)}
>
<span className="typography-micro whitespace-nowrap font-medium capitalize">{label}</span>
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</div>
);
if (disabled) return trigger;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-w-[220px]">
<DropdownMenuItem className="typography-meta" onSelect={() => onChange('')}>
<span className={cn('font-medium', !value && 'text-primary')}>
{t('rightSidebar.contextNotesTodo.sendDialog.variant.default')}
</span>
</DropdownMenuItem>
{options.map((option) => (
<DropdownMenuItem
key={option}
className="typography-meta"
onSelect={() => onChange(option)}
>
<span className={cn('font-medium capitalize', value === option && 'text-primary')}>
{option}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
export function TodoSendDialog(props: TodoSendDialogProps) {
const { t } = useI18n();
const { open, onOpenChange, target, projectDirectory, submitting = false, onConfirm } = props;
@@ -196,18 +139,18 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!submitting) onOpenChange(nextOpen); }}>
<DialogContent className="max-w-2xl overflow-visible">
<DialogContent className="max-w-md overflow-visible">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="typography-meta font-medium text-muted-foreground">{t('chat.modelControls.model')}</span>
<ModelSelector
providerId={execution.providerID}
modelId={execution.modelID}
className="w-full justify-between"
className="max-w-[320px] justify-between"
dropdownPortalToBody
onChange={(providerID, modelID) => {
setExecution((prev) => ({ ...prev, providerID, modelID, variant: '' }));
@@ -228,6 +171,7 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
<AgentSelector
agentName={execution.agent}
filter={agentFilter}
dropdownPortalToBody
onChange={(agent) => setExecution((prev) => ({ ...prev, agent }))}
/>
</div>