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"
>
@@ -412,7 +412,7 @@ export const ContextPanel: React.FC = () => {
: activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'plan'
? <PlanView />
? <PlanView targetPath={activeTab.targetPath} />
: null;
const chatTabs = React.useMemo(
@@ -100,6 +100,30 @@ const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
{ id: 'git.integrate.cherrypick.resolve.instructions', title: 'Instructions' },
],
},
'plan.improve': {
title: 'Improve Plan',
description: 'Hidden prompt used when sending a saved plan into an improve flow.',
blocks: [
{ id: 'plan.improve.visible', title: 'Visible Prompt' },
{ id: 'plan.improve.instructions', title: 'Instructions' },
],
},
'plan.todo': {
title: 'Todo Planning',
description: 'Hidden prompt used when sending a todo into a new planning session.',
blocks: [
{ id: 'plan.todo.visible', title: 'Visible Prompt' },
{ id: 'plan.todo.instructions', title: 'Instructions' },
],
},
'plan.implement': {
title: 'Implement Plan',
description: 'Hidden prompt used when sending a saved plan into an implement flow.',
blocks: [
{ id: 'plan.implement.visible', title: 'Visible Prompt' },
{ id: 'plan.implement.instructions', title: 'Instructions' },
],
},
};
const hasOwn = (input: Record<string, string>, key: string) => Object.prototype.hasOwnProperty.call(input, key);
@@ -32,6 +32,14 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
{ id: 'github.pr.comment.single', title: 'Single PR Comment Review' },
],
},
{
group: 'Planning',
items: [
{ id: 'plan.todo', title: 'Todo Planning' },
{ id: 'plan.improve', title: 'Improve Plan' },
{ id: 'plan.implement', title: 'Implement Plan' },
],
},
] as const;
}, []);
@@ -0,0 +1,71 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
type SaveProjectPlanDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
initialTitle: string;
sourceText: string;
saving?: boolean;
onSave: (title: string) => Promise<void> | void;
};
export function SaveProjectPlanDialog(props: SaveProjectPlanDialogProps) {
const { open, onOpenChange, initialTitle, sourceText, saving = false, onSave } = props;
const [title, setTitle] = React.useState(initialTitle);
React.useEffect(() => {
if (open) {
setTitle(initialTitle);
}
}, [initialTitle, open]);
const trimmedTitle = title.trim();
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!saving) onOpenChange(nextOpen); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Save as plan</DialogTitle>
<DialogDescription>Choose a title for the saved markdown plan file.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">Title</label>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Plan title"
autoFocus
disabled={saving}
/>
</div>
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">Content preview</label>
<div className="max-h-40 overflow-auto rounded-lg border border-border/70 bg-[var(--surface-subtle)] px-3 py-2 typography-meta text-foreground">
{sourceText}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>Cancel</Button>
<Button onClick={() => void onSave(trimmedTitle)} disabled={!trimmedTitle || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+319 -46
View File
@@ -5,6 +5,13 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
@@ -13,14 +20,37 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
import { RiCheckLine, RiClipboardLine, RiCodeAiLine, RiLoopRightAiLine } from '@remixicon/react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { EditorView } from '@codemirror/view';
import { copyTextToClipboard } from '@/lib/clipboard';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
import { renderMagicPrompt } from '@/lib/magicPrompts';
type PlanViewProps = {
targetPath?: string | null;
};
type PlanSendAction = 'improve' | 'implement';
type PlanSendTarget = 'session' | 'worktree';
type PendingPlanSend = {
action: PlanSendAction;
target: PlanSendTarget;
};
const normalize = (value: string): string => {
if (!value) return '';
@@ -75,16 +105,57 @@ const toDisplayPath = (resolvedPath: string, options: { currentDirectory: string
return normalized;
};
const resolveProjectRefForDirectory = (
directory: string,
projects: Array<{ id: string; path: string }>,
activeProjectId: string | null,
): { id: string; path: string } | null => {
const normalized = normalize(directory.trim());
if (!normalized) {
return null;
}
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (activeProject?.path) {
const activePath = normalize(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const match = projects
.filter((project) => {
const projectPath = normalize(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
})
.sort((left, right) => normalize(right.path).length - normalize(left.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
type SelectedLineRange = {
start: number;
end: number;
};
export const PlanView: React.FC = () => {
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const sessions = useSessions();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const gitDirectories = useGitStore((state) => state.directories);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const runtimeApis = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const { currentTheme } = useThemeSystem();
@@ -99,6 +170,20 @@ export const PlanView: React.FC = () => {
const raw = typeof session?.directory === 'string' ? session.directory : '';
return normalize(raw || '');
}, [session?.directory]);
const projectDirectory = React.useMemo(
() => normalize(effectiveDirectory || sessionDirectory),
[effectiveDirectory, sessionDirectory],
);
const currentProjectRef = React.useMemo(
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
[activeProjectId, projectDirectory, projects],
);
const canCreateWorktree = React.useMemo(
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
[currentProjectRef, gitDirectories],
);
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
const displayPath = React.useMemo(() => {
@@ -108,14 +193,20 @@ export const PlanView: React.FC = () => {
return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory });
}, [resolvedPath, sessionDirectory, homeDirectory]);
const [content, setContent] = React.useState<string>('');
const [saveError, setSaveError] = React.useState<string | null>(null);
const planFileLabel = React.useMemo(() => {
return displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
}, [displayPath]);
const parsedTitle = React.useMemo(() => {
if (!content.trim()) {
return 'Plan';
}
return parseProjectPlanMarkdown(content).title || 'Plan';
}, [content]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || 'Plan', [parsedTitle]);
const [loading, setLoading] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [copiedContent, setCopiedContent] = React.useState(false);
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
const copiedTimeoutRef = React.useRef<number | null>(null);
const copiedContentTimeoutRef = React.useRef<number | null>(null);
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
@@ -256,8 +347,8 @@ export const PlanView: React.FC = () => {
}, [currentTheme, resolvedPath]);
React.useEffect(() => {
// Early exit if plan mode is disabled - don't load anything
if (!planModeEnabled) {
// Saved project plans opened via context panel should work even when session plan mode is off.
if (!planModeEnabled && !targetPath) {
setResolvedPath(null);
setContent('');
setLoading(false);
@@ -282,6 +373,24 @@ export const PlanView: React.FC = () => {
const run = async () => {
setResolvedPath(null);
setContent('');
setSaveError(null);
if (targetPath) {
setLoading(true);
try {
const text = await readText(targetPath);
if (cancelled) return;
setResolvedPath(targetPath);
setContent(text);
} catch {
if (cancelled) return;
setResolvedPath(null);
setContent('');
} finally {
if (!cancelled) setLoading(false);
}
return;
}
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
setResolvedPath(null);
@@ -338,19 +447,141 @@ export const PlanView: React.FC = () => {
return () => {
cancelled = true;
};
}, [planModeEnabled, sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]);
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
React.useEffect(() => {
if (!resolvedPath) {
setSaveError(null);
return;
}
const controller = window.setTimeout(async () => {
setSaveError(null);
try {
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
throw new Error('Write failed');
}
} else {
const response = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: resolvedPath, content }),
});
if (!response.ok) {
throw new Error(`Failed to write plan file (${response.status})`);
}
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Failed to save');
}
}, 350);
return () => {
window.clearTimeout(controller);
};
}, [content, resolvedPath, runtimeApis.files]);
React.useEffect(() => {
return () => {
if (copiedTimeoutRef.current !== null) {
window.clearTimeout(copiedTimeoutRef.current);
}
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
};
}, []);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const handleConfirmPlanSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!currentProjectRef || !pendingPlanSend) {
return;
}
const visiblePrompt = await renderMagicPrompt(
pendingPlanSend.action === 'improve' ? 'plan.improve.visible' : 'plan.implement.visible',
{
plan_title: sendPromptTitle,
},
);
const instructionsText = await renderMagicPrompt(
pendingPlanSend.action === 'improve' ? 'plan.improve.instructions' : 'plan.implement.instructions',
{
plan_title: sendPromptTitle,
plan_path: resolvedPath ?? '',
},
);
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
setIsPlanSendSubmitting(true);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = currentProjectRef.path;
if (pendingPlanSend.target === 'worktree') {
if (!canCreateWorktree) {
return;
}
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = null;
} else {
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
if (!sessionResult?.id) {
return;
}
sessionId = sessionResult.id;
directoryHint = sessionResult.directory ?? currentProjectRef.path;
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
}
if (!sessionId) {
return;
}
const selectionState = useSelectionStore.getState();
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
if (execution.agent.trim()) {
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
selectionState.saveAgentModelVariantForSession(
sessionId,
execution.agent,
execution.providerID,
execution.modelID,
execution.variant || undefined,
);
}
setCurrentSession(sessionId, directoryHint);
await sendMessage(
visiblePrompt,
execution.providerID,
execution.modelID,
execution.agent.trim() || undefined,
undefined,
undefined,
syntheticParts,
execution.variant || undefined,
);
setPendingPlanSend(null);
} finally {
setIsPlanSendSubmitting(false);
}
},
[canCreateWorktree, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
);
const blockWidgets = React.useMemo(() => {
return buildCodeMirrorCommentWidgets({
drafts: planFileDrafts,
@@ -375,15 +606,73 @@ export const PlanView: React.FC = () => {
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate">Plan</div>
{resolvedPath ? (
<div className="typography-meta text-muted-foreground truncate" title={displayPath ?? resolvedPath}>
{displayPath ?? resolvedPath}
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
{saveError ? (
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
Save failed
</div>
) : null}
</div>
{resolvedPath ? (
<div className="flex items-center gap-1">
<DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label="Improve plan"
disabled={!content.trim()}
>
<RiLoopRightAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Improve</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
Send to new session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'improve', target: 'worktree' })}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label="Implement plan"
disabled={!content.trim()}
>
<RiCodeAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Implement</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
Send to new session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'implement', target: 'worktree' })}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PreviewToggleButton
currentMode={mdViewMode}
onToggle={() => saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')}
@@ -415,44 +704,30 @@ export const PlanView: React.FC = () => {
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(displayPath ?? resolvedPath);
if (result.ok) {
setCopiedPath(true);
if (copiedTimeoutRef.current !== null) {
window.clearTimeout(copiedTimeoutRef.current);
}
copiedTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} else {
// ignored
}
}}
className="h-5 w-5 p-0"
title={`Copy plan path (${displayPath ?? resolvedPath})`}
aria-label={`Copy plan path (${displayPath ?? resolvedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
</div>
) : null}
</div>
<TodoSendDialog
open={pendingPlanSend !== null}
onOpenChange={(open) => {
if (!open && !isPlanSendSubmitting) {
setPendingPlanSend(null);
}
}}
target={pendingPlanSend?.target ?? 'session'}
projectDirectory={currentProjectRef?.path ?? null}
submitting={isPlanSendSubmitting}
onConfirm={handleConfirmPlanSend}
/>
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{loading ? (
<div className="p-3 typography-ui text-muted-foreground">Loading</div>
) : (
<div className="relative h-full">
<div className="h-full oc-plan-editor">
<div className="h-full">
{mdViewMode === 'preview' ? (
<div className="h-full overflow-auto p-3">
<ErrorBoundary
@@ -472,10 +747,8 @@ export const PlanView: React.FC = () => {
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={() => {
// read-only
}}
readOnly={true}
onChange={setContent}
readOnly={false}
className="h-full"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
-6
View File
@@ -66,12 +66,6 @@ textarea[data-chat-input="true"]:focus-visible {
background: color-mix(in srgb, var(--accent) 70%, transparent);
}
.oc-plan-editor .cm-scroller,
.oc-plan-editor .cm-content,
.oc-plan-editor .cm-gutters {
font-family: "IBM Plex Sans", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
}
/* Codemirror syntax fallback for classHighlighter tokens */
.cm-editor .tok-comment,
.cm-editor .tok-docComment,
+94 -2
View File
@@ -16,13 +16,19 @@ export type MagicPromptId =
| 'github.pr.comments.review.visible'
| 'github.pr.comments.review.instructions'
| 'github.pr.comment.single.visible'
| 'github.pr.comment.single.instructions';
| 'github.pr.comment.single.instructions'
| 'plan.todo.visible'
| 'plan.todo.instructions'
| 'plan.improve.visible'
| 'plan.improve.instructions'
| 'plan.implement.visible'
| 'plan.implement.instructions';
export interface MagicPromptDefinition {
id: MagicPromptId;
title: string;
description: string;
group: 'Git' | 'GitHub';
group: 'Git' | 'GitHub' | 'Planning';
template: string;
placeholders?: Array<{ key: string; description: string }>;
}
@@ -348,6 +354,92 @@ Important:
- Do not leave any files with unresolved conflict markers
- After completing all steps, confirm the cherry-pick was successful`,
},
{
id: 'plan.todo.visible',
title: 'Todo Planning Visible Prompt',
group: 'Planning',
description: 'Visible user message when sending a todo into a new planning session.',
placeholders: [
{ key: 'todo_text', description: 'Todo text selected by the user.' },
],
template: '{{todo_text}}',
},
{
id: 'plan.todo.instructions',
title: 'Todo Planning Instructions',
group: 'Planning',
description: 'Hidden instructions for sending a project todo into a new planning session.',
placeholders: [
{ key: 'todo_text', description: 'Todo text selected by the user.' },
],
template: `You are starting from a project todo item.
Todo: {{todo_text}}
Your job right now is to produce an implementation plan for this todo, not to implement it yet.
Before writing the plan, inspect the repository and gather the necessary context from relevant files, module docs, existing patterns, and nearby code.
Identify the affected areas, constraints, dependencies, likely risks, and validation steps based on the actual repo state.
Then provide a concrete implementation plan grounded in that repo context. Make assumptions and missing context explicit.`,
},
{
id: 'plan.improve.visible',
title: 'Improve Plan Visible Prompt',
group: 'Planning',
description: 'Visible user message when sending a saved plan into an improve flow.',
placeholders: [
{ key: 'plan_title', description: 'Current plan title.' },
],
template: 'Improve this plan: {{plan_title}}',
},
{
id: 'plan.improve.instructions',
title: 'Improve Plan Instructions',
group: 'Planning',
description: 'Hidden instructions for improving a saved plan from project context.',
placeholders: [
{ key: 'plan_title', description: 'Current plan title.' },
{ key: 'plan_path', description: 'Absolute path to the saved plan file.' },
],
template: `You are starting from an existing implementation plan.
Plan title: {{plan_title}}
This plan is stored in the file: {{plan_path}}
Read that file first and treat its current contents as the source of truth for the plan.
First inspect the repository and gather the necessary context from relevant files, module docs, existing patterns, and nearby code.
Your main goal in this task is to improve the plan so it is better grounded in the actual repo state.
Do not implement yet. Produce an improved implementation plan, call out assumptions, missing context, risks, and validation steps.
Discuss important plan decisions, tradeoffs, gaps, or risks with the user in a concise and understandable way.
Keep the response short and to the point. Do not dump a long wall of text.
Prefer a short summary of proposed changes, open questions, and recommendations over rewriting the whole plan inline.
Do not return the full plan as a markdown code block or fenced block.
If useful, quote only small targeted snippets or describe the exact sections that should change.
After you finish researching, propose the improved plan and explicitly offer to edit this same file with those plan changes.`,
},
{
id: 'plan.implement.visible',
title: 'Implement Plan Visible Prompt',
group: 'Planning',
description: 'Visible user message when sending a saved plan into an implement flow.',
placeholders: [
{ key: 'plan_title', description: 'Current plan title.' },
],
template: 'Implement this plan: {{plan_title}}',
},
{
id: 'plan.implement.instructions',
title: 'Implement Plan Instructions',
group: 'Planning',
description: 'Hidden instructions for implementing a saved plan from project context.',
placeholders: [
{ key: 'plan_title', description: 'Current plan title.' },
{ key: 'plan_path', description: 'Absolute path to the saved plan file.' },
],
template: `You are starting from an existing implementation plan.
Plan title: {{plan_title}}
This plan is stored in the file: {{plan_path}}
Read that file first and treat its current contents as the source of truth for the plan.
Use this plan as task context and begin implementing it.
Before and during implementation, inspect the repository and gather the necessary context from relevant files, module docs, existing patterns, and nearby code.
Do the implementation work. If you discover mismatches between the plan and the repo reality, make those adjustments explicit and continue with implementation using the corrected understanding.
If implementation reveals plan adjustments, explicitly tell the user those plan changes should be saved back into this same file.`,
},
] as const;
const MAGIC_PROMPT_DEFINITION_BY_ID = new Map<MagicPromptId, MagicPromptDefinition>(
@@ -11,3 +11,21 @@ export const flattenAssistantTextParts = (parts: Part[]): string => {
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
};
export const suggestPlanTitleFromText = (text: string): string => {
const normalized = text
.replace(/\r\n?/g, '\n')
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0) || 'Plan';
const cleaned = normalized
.replace(/^#+\s*/, '')
.replace(/^[-*+]\s+/, '')
.replace(/^\d+\.\s+/, '');
const sentenceMatch = cleaned.match(/(.+?[.!?])(?:\s|$)/);
const firstSentence = sentenceMatch?.[1] || cleaned;
const compact = firstSentence.replace(/\s+/g, ' ').trim();
return compact.length > 160 ? compact.slice(0, 160).trim() : compact || 'Plan';
};