diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 25d4eb63..47081b2e 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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 && ( - - - + + {isSharing ? 'Saving image...' : 'Save as image'} + + + + - {isSharing ? 'Saving image...' : 'Save as image'} + Save as plan - - - + + + + + ); +} diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 9c396032..e6cb6805 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -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 = ({ 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(null); + const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false); const [resolvedPath, setResolvedPath] = React.useState(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(''); + const [saveError, setSaveError] = React.useState(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(null); const copiedContentTimeoutRef = React.useRef(null); const [lineSelection, setLineSelection] = React.useState(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 = () => {
-
Plan
- {resolvedPath ? ( -
- {displayPath ?? resolvedPath} +
{parsedTitle}
+ {saveError ? ( +
+ Save failed
) : null}
{resolvedPath ? (
+ + + + + + + + Improve + + + setPendingPlanSend({ action: 'improve', target: 'session' })}> + Send to new session + + setPendingPlanSend({ action: 'improve', target: 'worktree' })} + disabled={!canCreateWorktree} + > + Send to new worktree session + + + + + + + + + + + Implement + + + setPendingPlanSend({ action: 'implement', target: 'session' })}> + Send to new session + + setPendingPlanSend({ action: 'implement', target: 'worktree' })} + disabled={!canCreateWorktree} + > + Send to new worktree session + + + saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')} @@ -415,44 +704,30 @@ export const PlanView: React.FC = () => { )} -
) : null}
+ { + if (!open && !isPlanSendSubmitting) { + setPendingPlanSend(null); + } + }} + target={pendingPlanSend?.target ?? 'session'} + projectDirectory={currentProjectRef?.path ?? null} + submitting={isPlanSendSubmitting} + onConfirm={handleConfirmPlanSend} + /> +
{loading ? (
Loading…
) : (
-
+
{mdViewMode === 'preview' ? (
{
{ - // read-only - }} - readOnly={true} + onChange={setContent} + readOnly={false} className="h-full" extensions={editorExtensions} onViewReady={(view) => { editorViewRef.current = view; }} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 5697e5f4..10a9762c 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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, diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 9891e7d1..e2a27ad7 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -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( diff --git a/packages/ui/src/lib/messages/messageText.ts b/packages/ui/src/lib/messages/messageText.ts index b4173ebe..8bca994f 100644 --- a/packages/ui/src/lib/messages/messageText.ts +++ b/packages/ui/src/lib/messages/messageText.ts @@ -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'; +};