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:
committed by
GitHub
parent
aefbdbc52f
commit
6a88cd09cc
@@ -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>
|
||||
|
||||
@@ -1692,6 +1692,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.saveAsPlan': 'Save as plan',
|
||||
'chat.messageBody.actions.startNewSession': 'Start new session from this answer',
|
||||
'chat.messageBody.actions.startNewMultiRun': 'Start new multi-run from this answer',
|
||||
'chat.messageBody.forkDialog.instructions.label': 'Instructions',
|
||||
'chat.messageBody.forkDialog.instructions.placeholder': 'Add instructions for the new session…',
|
||||
'chat.generatedResult.actions.copy': 'Copy',
|
||||
'chat.generatedResult.actions.copied': 'Copied',
|
||||
'chat.generatedResult.commit.title': 'Generated commit message',
|
||||
|
||||
@@ -1658,6 +1658,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.saveAsPlan": "Guardar como plan",
|
||||
"chat.messageBody.actions.startNewSession": "Iniciar nueva sesión desde esta respuesta",
|
||||
"chat.messageBody.actions.startNewMultiRun": "Iniciar nuevo multi-run desde esta respuesta",
|
||||
"chat.messageBody.forkDialog.instructions.label": "Instrucciones",
|
||||
"chat.messageBody.forkDialog.instructions.placeholder": "Añade instrucciones para la nueva sesión…",
|
||||
"chat.generatedResult.actions.copy": "Copiar",
|
||||
"chat.generatedResult.actions.copied": "Copiado",
|
||||
"chat.generatedResult.commit.title": "Mensaje de commit generado",
|
||||
|
||||
@@ -1692,6 +1692,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.saveAsPlan': '플랜으로 저장',
|
||||
'chat.messageBody.actions.startNewSession': '이 응답에서 새 세션 시작',
|
||||
'chat.messageBody.actions.startNewMultiRun': '이 응답에서 새 멀티런 시작',
|
||||
'chat.messageBody.forkDialog.instructions.label': '지침',
|
||||
'chat.messageBody.forkDialog.instructions.placeholder': '새 세션에 대한 지침을 입력하세요…',
|
||||
'chat.generatedResult.actions.copy': '복사',
|
||||
'chat.generatedResult.actions.copied': '복사됨',
|
||||
'chat.generatedResult.commit.title': '생성된 커밋 메시지',
|
||||
|
||||
@@ -677,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.saveAsPlan': 'Zapisz jako plan',
|
||||
'chat.messageBody.actions.startNewSession': 'Rozpocznij nową sesję z tej odpowiedzi',
|
||||
'chat.messageBody.actions.startNewMultiRun': 'Rozpocznij nowe wielokrotne uruchomienie z tej odpowiedzi',
|
||||
'chat.messageBody.forkDialog.instructions.label': 'Instrukcje',
|
||||
'chat.messageBody.forkDialog.instructions.placeholder': 'Dodaj instrukcje dla nowej sesji…',
|
||||
'chat.generatedResult.actions.copy': 'Kopiuj',
|
||||
'chat.generatedResult.actions.copied': 'Skopiowano',
|
||||
'chat.generatedResult.commit.title': 'Wygenerowana wiadomość commita',
|
||||
|
||||
@@ -1658,6 +1658,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.saveAsPlan": "Salvar como plano",
|
||||
"chat.messageBody.actions.startNewSession": "Iniciar nova sessão a partir desta resposta",
|
||||
"chat.messageBody.actions.startNewMultiRun": "Iniciar novo multi-run a partir desta resposta",
|
||||
"chat.messageBody.forkDialog.instructions.label": "Instruções",
|
||||
"chat.messageBody.forkDialog.instructions.placeholder": "Adicione instruções para a nova sessão…",
|
||||
"chat.generatedResult.actions.copy": "Copiar",
|
||||
"chat.generatedResult.actions.copied": "Copiado",
|
||||
"chat.generatedResult.commit.title": "Mensagem de commit gerada",
|
||||
|
||||
@@ -1658,6 +1658,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.saveAsPlan": "Зберегти як план",
|
||||
"chat.messageBody.actions.startNewSession": "Почати нову сесію із цієї відповіді",
|
||||
"chat.messageBody.actions.startNewMultiRun": "Почніть новий Multi-run із цієї відповіді",
|
||||
"chat.messageBody.forkDialog.instructions.label": "Інструкції",
|
||||
"chat.messageBody.forkDialog.instructions.placeholder": "Додайте інструкції для нової сесії…",
|
||||
"chat.generatedResult.actions.copy": "Копіювати",
|
||||
"chat.generatedResult.actions.copied": "Скопійовано",
|
||||
"chat.generatedResult.commit.title": "Згенероване повідомлення коміту",
|
||||
|
||||
@@ -1658,6 +1658,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.saveAsPlan': '保存为计划',
|
||||
'chat.messageBody.actions.startNewSession': '基于此回答开始新会话',
|
||||
'chat.messageBody.actions.startNewMultiRun': '基于此回答开始新的多运行',
|
||||
'chat.messageBody.forkDialog.instructions.label': '说明',
|
||||
'chat.messageBody.forkDialog.instructions.placeholder': '为新会话添加说明…',
|
||||
'chat.generatedResult.actions.copy': '复制',
|
||||
'chat.generatedResult.actions.copied': '已复制',
|
||||
'chat.generatedResult.commit.title': '生成的提交消息',
|
||||
|
||||
@@ -1662,6 +1662,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.saveAsPlan': '儲存為計畫',
|
||||
'chat.messageBody.actions.startNewSession': '基於此回答開始新會話',
|
||||
'chat.messageBody.actions.startNewMultiRun': '基於此回答開始新的 Multi-run',
|
||||
'chat.messageBody.forkDialog.instructions.label': '說明',
|
||||
'chat.messageBody.forkDialog.instructions.placeholder': '為新工作階段新增說明…',
|
||||
'chat.generatedResult.actions.copy': '複製',
|
||||
'chat.generatedResult.actions.copied': '已複製',
|
||||
'chat.generatedResult.commit.title': '生成的提交訊息',
|
||||
|
||||
@@ -14,3 +14,25 @@ export const MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT =
|
||||
|
||||
export const isExecutionForkMetaText = (text: string | null | undefined): boolean =>
|
||||
typeof text === 'string' && text.trim() === EXECUTION_FORK_META_TEXT.trim();
|
||||
|
||||
// Default, user-editable instructions prefilled in the "Start new session from
|
||||
// this answer" dialog. Mirrors the previous fixed fork instruction so existing
|
||||
// behavior is preserved unless the user edits it.
|
||||
export const EXECUTION_FORK_DEFAULT_INSTRUCTIONS =
|
||||
"I want you to respond according to the content of message I share: " +
|
||||
"if it is an implementation plan, your task is to implement that plan; " +
|
||||
"if it is a conclusion or summary, your task is to verify it, explain whether you agree or disagree, and correct it if needed. " +
|
||||
"Always clearly state what you understand your task to be, and wait for the user's approval of your conclusions before taking any further actions.";
|
||||
|
||||
// Fixed connective that opens the forked assistant content. Not editable by the
|
||||
// user — it sits between the user's instructions and the assistant message.
|
||||
export const EXECUTION_FORK_CONTENT_PREFACE =
|
||||
"This message below comes from an AI agent in another session. Here is the content of the message:";
|
||||
|
||||
// Builds the final message sent to the new session:
|
||||
// <user instructions>
|
||||
//
|
||||
// This message below comes from an AI agent in another session. Here is the content of the message:
|
||||
// <assistant content>
|
||||
export const composeForkSessionMessage = (instructions: string, assistantContent: string): string =>
|
||||
`${instructions.trim()}\n\n${EXECUTION_FORK_CONTENT_PREFACE}\n${assistantContent}`;
|
||||
|
||||
@@ -235,7 +235,7 @@ export interface SessionStore {
|
||||
closeNewSessionDraft: () => void;
|
||||
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise<void>;
|
||||
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useCommandsStore } from "@/stores/useCommandsStore"
|
||||
import { getSafeStorage } from "@/stores/utils/safeStorage"
|
||||
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
||||
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"
|
||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
||||
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
||||
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
|
||||
@@ -269,7 +269,7 @@ export type SessionUIState = {
|
||||
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
|
||||
handleSlashUndo: (sessionId: string) => Promise<void>
|
||||
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string }) => Promise<void>
|
||||
|
||||
// Data access helpers (read from sync)
|
||||
getSessionsByDirectory: (directory: string) => Session[]
|
||||
@@ -1167,8 +1167,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSessionFromAssistantMessage — reads from sync
|
||||
// ---------------------------------------------------------------------------
|
||||
createSessionFromAssistantMessage: async (sourceMessageId) => {
|
||||
createSessionFromAssistantMessage: async (sourceMessageId, execution) => {
|
||||
if (!sourceMessageId) return
|
||||
if (!execution?.instructions?.trim()) return
|
||||
|
||||
// Find which session this message belongs to by scanning sync state
|
||||
const state = getDirectoryState()
|
||||
@@ -1200,9 +1201,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
const session = await get().createSession(undefined, directory ?? null, null)
|
||||
if (!session) return
|
||||
|
||||
const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState()
|
||||
const pID = currentProviderId || useSelectionStore.getState().lastUsedProvider?.providerID
|
||||
const mID = currentModelId || useSelectionStore.getState().lastUsedProvider?.modelID
|
||||
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
|
||||
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
|
||||
|
||||
if (!pID || !mID) return
|
||||
|
||||
@@ -1211,9 +1211,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
id: session.id,
|
||||
providerID: pID,
|
||||
modelID: mID,
|
||||
text: assistantPlanText,
|
||||
prefaceText: EXECUTION_FORK_META_TEXT,
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: execution.variant || undefined,
|
||||
text: composeForkSessionMessage(execution.instructions, assistantPlanText),
|
||||
agent: execution.agent || undefined,
|
||||
directory: sessionDirectory,
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user