feat(plans): save assistant messages as editable project plans with improve/implement flows

Add Save-as-plan action on assistant messages that writes a markdown plan
into the project plans directory and suggests a title from the text. Plan
view opens a specific saved plan via targetPath, supports inline edits, and
can kick off improve or implement flows in a new or worktree session.

Ship the matching hidden magic prompts (plan.todo/improve/implement) so the
instructions applied by these flows are visible and editable in the Magic
Prompts page.

Remove the plan editor font override, CodeMirror now uses the app font stack.
This commit is contained in:
Bohdan Triapitsyn
2026-04-18 13:47:05 +03:00
parent d203ba2ae0
commit 799a202e99
9 changed files with 677 additions and 71 deletions
@@ -13,18 +13,20 @@ import { cn } from '@/lib/utils';
import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
import { useMessageTTS } from '@/hooks/useMessageTTS';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -35,6 +37,8 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { useSessions } from '@/sync/sync-context';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -75,6 +79,39 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
return `${providerID}/${modelID}`;
};
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
const resolveProjectRefForDirectory = (
directory: string,
projects: Array<{ id: string; path: string }>,
activeProjectId: string | null,
): { id: string; path: string } | null => {
const normalized = normalizePath(directory.trim());
if (!normalized) {
return null;
}
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (activeProject?.path) {
const activePath = normalizePath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const match = projects
.filter((project) => {
const projectPath = normalizePath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
})
.sort((left, right) => normalizePath(right.path).length - normalizePath(left.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
@@ -426,9 +463,9 @@ const UserMessageBody: React.FC<{
)}
>
{onRevert && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
@@ -697,9 +734,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const sessions = useSessions();
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = 7;
@@ -719,6 +764,18 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return `Read aloud (${providerLabel} voice)`;
}, [isTTSPlaying, voiceProvider]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessions.find((session) => session.id === currentSessionId) ?? null;
}, [currentSessionId, sessions]);
const currentProjectRef = React.useMemo(() => {
const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return resolveProjectRefForDirectory(directory, projects, activeProjectId);
}, [activeProjectId, currentSession?.directory, projects]);
const hasTools = toolParts.length > 0;
@@ -903,7 +960,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
event.stopPropagation();
event.preventDefault();
const assistantPlanText = flattenAssistantTextParts(assistantTextParts);
if (!assistantPlanText.trim()) {
return;
}
@@ -911,7 +967,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`;
openMultiRunLauncherWithPrompt(prefilledPrompt);
},
[assistantTextParts, openMultiRunLauncherWithPrompt]
[assistantPlanText, openMultiRunLauncherWithPrompt]
);
const handleTTSClick = React.useCallback(
@@ -924,12 +980,55 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return;
}
const messageText = flattenAssistantTextParts(assistantTextParts);
if (messageText.trim()) {
void playTTS(messageText);
if (assistantPlanText.trim()) {
void playTTS(assistantPlanText);
}
},
[assistantTextParts, isTTSPlaying, playTTS, stopTTS]
[assistantPlanText, isTTSPlaying, playTTS, stopTTS]
);
const handleSaveAsPlanClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!assistantPlanText.trim()) {
return;
}
setIsPlanDialogOpen(true);
},
[assistantPlanText]
);
const handleConfirmSaveAsPlan = React.useCallback(
async (title: string) => {
if (!assistantPlanText.trim()) {
return;
}
if (!currentProjectRef) {
toast.error('No project found for this session');
return;
}
setIsSavingPlan(true);
try {
const created = await createProjectPlanFile(currentProjectRef, {
title,
body: assistantPlanText,
});
if (!created) {
toast.error('Failed to save plan');
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success('Plan saved');
} finally {
setIsSavingPlan(false);
}
},
[assistantPlanText, currentProjectRef]
);
const [isSharing, setIsSharing] = React.useState(false);
@@ -1396,12 +1495,31 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<RiImageDownloadLine className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={!hasCopyableText || !currentProjectRef}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || !currentProjectRef) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleSaveAsPlanClick}
>
<RiBookletLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{isSharing ? 'Saving image...' : 'Save as image'}</TooltipContent>
<TooltipContent sideOffset={6}>Save as plan</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
@@ -1472,7 +1590,15 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
<TextSelectionMenu containerRef={messageContentRef} />
<div>
<SaveProjectPlanDialog
open={isPlanDialogOpen}
onOpenChange={setIsPlanDialogOpen}
initialTitle={suggestedPlanTitle}
sourceText={assistantPlanText}
saving={isSavingPlan}
onSave={handleConfirmSaveAsPlan}
/>
<div>
<div
className="message-content-text leading-relaxed overflow-hidden text-foreground/90 [&_p:last-child]:mb-0 [&_ul:last-child]:mb-0 [&_ol:last-child]:mb-0"
>