diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8f9c07d5..6d319620 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -12,6 +12,8 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; +import { appendInlineComments } from '@/lib/messages/inlineComments'; import { AttachedFilesList } from './FileAttachment'; import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; @@ -112,6 +114,20 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const addToQueue = useMessageQueueStore((state) => state.addToQueue); const clearQueue = useMessageQueueStore((state) => state.clearQueue); + // Inline comment drafts + const draftCount = useInlineCommentDraftStore( + React.useCallback( + (state) => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + if (!sessionKey) return 0; + return (state.drafts[sessionKey] ?? []).length; + }, + [currentSessionId, newSessionDraftOpen] + ) + ); + const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); + const hasDrafts = draftCount > 0; + // Session activity for auto-send on idle const { phase: sessionPhase } = useCurrentSessionActivity(); const prevSessionPhaseRef = React.useRef(sessionPhase); @@ -223,7 +239,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [pendingInputText, consumePendingInputText]); - const hasContent = message.trim() || attachedFiles.length > 0; + const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; @@ -233,7 +249,16 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; - const messageToQueue = message.replace(/^\n+|\n+$/g, ''); + // Get and consume drafts for this session + const sessionKey = currentSessionId; + const drafts = consumeDrafts(sessionKey); + + // Build message with appended drafts + let messageToQueue = message.replace(/^\n+|\n+$/g, ''); + if (drafts.length > 0) { + messageToQueue = appendInlineComments(messageToQueue, drafts); + } + const attachmentsToQueue = attachedFiles.map((file) => ({ ...file })); addToQueue(currentSessionId, { @@ -250,7 +275,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]); + }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -317,6 +342,28 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } + // Get session key for drafts (use currentSessionId or 'draft' for new sessions) + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + let drafts: import('@/stores/useInlineCommentDraftStore').InlineCommentDraft[] = []; + if (sessionKey) { + drafts = consumeDrafts(sessionKey); + } + + // Append drafts to the message if any exist + if (drafts.length > 0) { + if (queuedMessages.length === 0) { + // No queue - append to primary text + primaryText = appendInlineComments(primaryText, drafts); + } else if (additionalParts.length > 0) { + // Has queue with additional parts - append to the last part (current input) + const lastPart = additionalParts[additionalParts.length - 1]; + lastPart.text = appendInlineComments(lastPart.text, drafts); + } else { + // Has queue but no additional parts yet (shouldn't happen with hasContent check, but handle it) + primaryText = appendInlineComments(primaryText, drafts); + } + } + if (!primaryText && additionalParts.length === 0) return; // Clear queue and input @@ -1373,14 +1420,34 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo )} - { setMessage(content); setTimeout(() => { textareaRef.current?.focus(); }, 0); - }} + }} /> + {/* Review comments chip */} + {hasDrafts && ( +
+
+ Review comments: + + {draftCount} + +
+
+ )}
{ + if (left === right) return true; + if (!left || !right) return false; + return left.start === right.start && left.end === right.end && left.side === right.side; +}; + export const PierreDiffViewer: React.FC = ({ original, modified, @@ -160,12 +171,20 @@ export const PierreDiffViewer: React.FC = ({ themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDark; - const setActiveMainTab = useUIStore(state => state.setActiveMainTab); - const [selection, setSelection] = useState(null); const [commentText, setCommentText] = useState(''); const commentContainerRef = useRef(null); + // Refs to prevent infinite loops when syncing selection with diff instance + const selectionRef = useRef(null); + const isApplyingSelectionRef = useRef(false); + const lastAppliedSelectionRef = useRef(null); + + // Keep selectionRef in sync with state + useEffect(() => { + selectionRef.current = selection; + }, [selection]); + // Calculate initial center and width synchronously to avoid flicker const getMainContentMetrics = useCallback(() => { if (isMobile) return { center: '50%', width: '100vw' }; @@ -184,15 +203,29 @@ export const PierreDiffViewer: React.FC = ({ const mainContentCenter = mainContentMetrics.center; const mainContentWidth = mainContentMetrics.width; - const sendMessage = useSessionStore(state => state.sendMessage); const currentSessionId = useSessionStore(state => state.currentSessionId); - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); - const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection); - const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession); - const getAgentModelVariantForSession = useContextStore(state => state.getAgentModelVariantForSession); - const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); - const addToQueue = useMessageQueueStore(state => state.addToQueue); - const { phase: sessionPhase } = useCurrentSessionActivity(); + const newSessionDraftOpen = useSessionStore(state => state.newSessionDraft?.open); + + // Inline comment drafts + const addDraft = useInlineCommentDraftStore(state => state.addDraft); + const removeDraft = useInlineCommentDraftStore(state => state.removeDraft); + const allDrafts = useInlineCommentDraftStore(state => state.drafts); + + // Filter drafts locally to avoid returning new array refs from selector + const drafts = useMemo(() => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + if (!sessionKey) return []; + return (allDrafts[sessionKey] ?? []).filter( + d => d.source === 'diff' && d.fileLabel === fileName + ); + }, [currentSessionId, newSessionDraftOpen, fileName, allDrafts]); + + const { currentTheme } = useThemeSystem(); + + // Get session key for drafts + const getSessionKey = useCallback(() => { + return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + }, [currentSessionId, newSessionDraftOpen]); // Update main content metrics on resize useEffect(() => { @@ -206,13 +239,29 @@ export const PierreDiffViewer: React.FC = ({ return () => window.removeEventListener('resize', updateMetrics); }, [isMobile, getMainContentMetrics]); + // Stable handler that uses refs to avoid recreating on selection changes const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { + // Ignore callbacks while we're programmatically applying selection + if (isApplyingSelectionRef.current) { + return; + } + + const lastApplied = lastAppliedSelectionRef.current; + if (isSameSelection(range, lastApplied)) { + return; + } + + const currentSelection = selectionRef.current; + if (isSameSelection(range, currentSelection)) { + return; + } + // On mobile: implement "tap to extend" behavior // If user taps a new single line while we have an existing selection, extend the range - if (isMobile && range && selection && range.start === range.end) { + if (isMobile && range && currentSelection && range.start === range.end) { const tappedLine = range.start; - const existingStart = selection.start; - const existingEnd = selection.end; + const existingStart = currentSelection.start; + const existingEnd = currentSelection.end; // Extend the selection to include the tapped line const newStart = Math.min(existingStart, existingEnd, tappedLine); @@ -233,7 +282,7 @@ export const PierreDiffViewer: React.FC = ({ if (!range) { setCommentText(''); } - }, [isMobile, selection]); + }, [isMobile]); // Dismiss selection when clicking outside line numbers (desktop behavior) useEffect(() => { @@ -274,59 +323,41 @@ export const PierreDiffViewer: React.FC = ({ }; }, [selection]); - const handleSendComment = useCallback(async () => { - if (!selection || !commentText.trim()) return; - if (!currentSessionId) { - toast.error('Select a session to send comment'); - return; - } - - // Get session-specific agent/model/variant with fallback to config values - const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName; - const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null; - const effectiveProviderId = sessionModel?.providerId || currentProviderId; - const effectiveModelId = sessionModel?.modelId || currentModelId; - - if (!effectiveProviderId || !effectiveModelId) { - toast.error('Select a model to send comment'); - return; - } - - const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId - ? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant - : currentVariant; - - const code = extractSelectedCode(original, modified, selection); - const startLine = selection.start; - const endLine = selection.end; - const side = selection.side === 'deletions' ? 'original' : 'modified'; - - const message = `Comment on \`${fileName}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; - - // Clear state and switch tab immediately for responsive UX + const handleCancelComment = useCallback(() => { setCommentText(''); setSelection(null); - setActiveMainTab('chat'); + }, []); - // Check if should queue instead of send - const canQueue = sessionPhase !== 'idle'; - if (queueModeEnabled && canQueue) { - addToQueue(currentSessionId, { content: message }); - } else { - void sendMessage( - message, - effectiveProviderId, - effectiveModelId, - sessionAgent, - undefined, - undefined, - undefined, - effectiveVariant - ).catch((e) => { - console.error('Failed to send comment', e); - }); + const handleSaveComment = useCallback(() => { + if (!selection || !commentText.trim()) return; + + const sessionKey = getSessionKey(); + if (!sessionKey) { + toast.error('Select a session to save comment'); + return; } - }, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession, queueModeEnabled, sessionPhase, addToQueue]); + + const code = extractSelectedCode(original, modified, selection); + const side = selection.side === 'deletions' ? 'original' : 'modified'; + + addDraft({ + sessionKey, + source: 'diff', + fileLabel: fileName, + startLine: selection.start, + endLine: selection.end, + side, + code, + language, + text: commentText.trim(), + }); + + // Clear selection and comment text + setCommentText(''); + setSelection(null); + + toast.success('Comment saved'); + }, [selection, commentText, original, modified, fileName, language, addDraft, getSessionKey]); ensurePierreThemeRegistered(lightTheme); ensurePierreThemeRegistered(darkTheme); @@ -449,6 +480,7 @@ export const PierreDiffViewer: React.FC = ({ const instance = new PierreFileDiff(options as unknown as FileDiffOptions, workerPool); diffInstanceRef.current = instance; + lastAppliedSelectionRef.current = null; const oldFile: FileContents = { name: fileName, @@ -482,10 +514,27 @@ export const PierreDiffViewer: React.FC = ({ useEffect(() => { const instance = diffInstanceRef.current; if (!instance) return; + + // Only push selection to the diff when clearing. + // User-driven selections already originate from the diff itself. + if (selection !== null) { + return; + } + + // Guard against feedback loops and redundant updates + const lastApplied = lastAppliedSelectionRef.current; + if (isSameSelection(selection, lastApplied)) { + return; + } + try { + isApplyingSelectionRef.current = true; instance.setSelectedLines(selection); + lastAppliedSelectionRef.current = selection; } catch { // ignore + } finally { + isApplyingSelectionRef.current = false; } }, [selection]); @@ -527,50 +576,48 @@ export const PierreDiffViewer: React.FC = ({ onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - handleSendComment(); + handleSaveComment(); } if (e.key === 'Escape') { e.preventDefault(); - setSelection(null); - setCommentText(''); + handleCancelComment(); } }} /> - {/* Footer */} -
+ {/* Footer with Cancel and Comment buttons */} +
{fileName.split('/').pop()}:{selection.start}-{selection.end} -
+
{!isMobile && ( {getModifierLabel()}+⏎ )} - + Cancel + +
@@ -578,6 +625,70 @@ export const PierreDiffViewer: React.FC = ({ ); }; + // Render saved comment cards + const renderSavedComments = () => { + if (drafts.length === 0) return null; + + return ( +
+ {drafts.map(draft => { + // Approximate line placement; shadow DOM prevents direct line querying. + const lineHeight = 24; + const top = (draft.startLine - 1) * lineHeight; + + return ( +
+
+
+
+
+ {draft.fileLabel}:{draft.startLine}-{draft.endLine} + {draft.side && ` (${draft.side})`} +
+
{draft.text}
+
+ + + + + + removeDraft(draft.sessionKey, draft.id)} + className="text-destructive" + > + + Delete comment + + + +
+
+
+ ); + })} +
+ ); + }; + const commentContent = renderCommentContent(); // If we're in the main diff view ('fill' layout), render In-Flow (like ChatInput). @@ -597,8 +708,9 @@ export const PierreDiffViewer: React.FC = ({ disableHorizontal={false} fillContainer={true} > -
+
+ {renderSavedComments()}
@@ -630,8 +742,9 @@ export const PierreDiffViewer: React.FC = ({ // Use simple div with overflow-x-auto to avoid nested ScrollableOverlay issues in Chrome return (
-
+
+ {renderSavedComments()}
{selection && createPortal( diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index a4723841..4cd805f0 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -6,8 +6,6 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { cn, getModifierLabel } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; @@ -16,11 +14,19 @@ 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, RiSendPlane2Line } from '@remixicon/react'; +import { RiCheckLine, RiClipboardLine, RiFileCopy2Line, RiMoreLine, RiDeleteBinLine } from '@remixicon/react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; +import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; +import { toast } from '@/components/ui'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; const normalize = (value: string): string => { if (!value) return ''; @@ -85,17 +91,22 @@ export const PlanView: React.FC = () => { const sessions = useSessionStore((state) => state.sessions); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const runtimeApis = useRuntimeAPIs(); - const sendMessage = useSessionStore((state) => state.sendMessage); - const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); - const getSessionAgentSelection = useContextStore((state) => state.getSessionAgentSelection); - const getAgentModelForSession = useContextStore((state) => state.getAgentModelForSession); - const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); const { inputBarOffset, isKeyboardOpen } = useUIStore(); const { isMobile } = useDeviceInfo(); const { currentTheme } = useThemeSystem(); React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); + // Inline comment drafts + const addDraft = useInlineCommentDraftStore((state) => state.addDraft); + const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft); + const allDrafts = useInlineCommentDraftStore((state) => state.drafts); + + // Get session key for drafts + const getSessionKey = React.useCallback(() => { + return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + }, [currentSessionId, newSessionDraftOpen]); + const session = React.useMemo(() => { if (!currentSessionId) return null; return sessions.find((s) => s.id === currentSessionId) ?? null; @@ -195,65 +206,40 @@ export const PlanView: React.FC = () => { return lines.slice(startLine - 1, endLine).join('\n'); }, []); - const handleSendComment = React.useCallback(async () => { + const handleCancelComment = React.useCallback(() => { + setCommentText(''); + setLineSelection(null); + }, []); + + const handleSaveComment = React.useCallback(() => { if (!lineSelection || !commentText.trim()) return; - if (!currentSessionId) return; - const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName; - const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null; - const effectiveProviderId = sessionModel?.providerId || currentProviderId; - const effectiveModelId = sessionModel?.modelId || currentModelId; - - if (!effectiveProviderId || !effectiveModelId) { + const sessionKey = getSessionKey(); + if (!sessionKey) { + toast.error('Select a session to save comment'); return; } - const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId - ? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant - : currentVariant; - - const startLine = lineSelection.start; - const endLine = lineSelection.end; const code = extractSelectedCode(content, lineSelection); const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan'; const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown'; - const message = `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; + addDraft({ + sessionKey, + source: 'plan', + fileLabel, + startLine: lineSelection.start, + endLine: lineSelection.end, + code, + language, + text: commentText.trim(), + }); setCommentText(''); setLineSelection(null); - setActiveMainTab('chat'); - void sendMessage( - message, - effectiveProviderId, - effectiveModelId, - sessionAgent, - undefined, - undefined, - undefined, - effectiveVariant - ).catch(() => { - // ignore - }); - }, [ - lineSelection, - commentText, - currentSessionId, - currentProviderId, - currentModelId, - currentAgentName, - currentVariant, - content, - resolvedPath, - displayPath, - extractSelectedCode, - sendMessage, - setActiveMainTab, - getSessionAgentSelection, - getAgentModelForSession, - getAgentModelVariantForSession, - ]); + toast.success('Comment saved'); + }, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey]); const editorExtensions = React.useMemo(() => { const extensions = [createFlexokiCodeMirrorTheme(currentTheme)]; @@ -388,47 +374,48 @@ export const PlanView: React.FC = () => { onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - handleSendComment(); + handleSaveComment(); } if (e.key === 'Escape') { e.preventDefault(); - setLineSelection(null); - setCommentText(''); + handleCancelComment(); } }} /> -
+ {/* Footer with Cancel and Comment buttons */} +
Plan:{lineSelection.start}-{lineSelection.end} -
+
{!isMobile && ( {getModifierLabel()}+⏎ )} - + Cancel + +
@@ -436,6 +423,79 @@ export const PlanView: React.FC = () => { ); }; + // Render saved comment cards + const renderSavedComments = () => { + if (mdViewMode === 'preview') return null; + + const sessionKey = getSessionKey(); + if (!sessionKey) return null; + + const sessionDrafts = allDrafts[sessionKey] ?? []; + const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan'; + const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel); + + if (fileDrafts.length === 0) return null; + + return ( +
+ {fileDrafts.map((draft) => { + // For CodeMirror, we need to position based on line + // This is a simplified version - in production, you'd query the editor's DOM + const lineHeight = 24; // Approximate line height in pixels + const top = (draft.startLine - 1) * lineHeight; + + return ( +
+
+
+
+
+ {draft.fileLabel}:{draft.startLine}-{draft.endLine} +
+
{draft.text}
+
+ + + + + + removeDraft(draft.sessionKey, draft.id)} + className="text-destructive" + > + + Delete comment + + + +
+
+
+ ); + })} +
+ ); + }; + return (
@@ -544,54 +604,55 @@ export const PlanView: React.FC = () => {
) : ( - { - // read-only - }} - readOnly={true} - className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)]" - extensions={editorExtensions} - highlightLines={lineSelection - ? { - start: Math.min(lineSelection.start, lineSelection.end), - end: Math.max(lineSelection.start, lineSelection.end), - } - : undefined} - lineNumbersConfig={{ - domEventHandlers: { - mousedown: (view, line, event) => { - if (!(event instanceof MouseEvent)) return false; - if (event.button !== 0) return false; - event.preventDefault(); - const lineNumber = view.state.doc.lineAt(line.from).number; +
+ { + // read-only + }} + readOnly={true} + className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)]" + extensions={editorExtensions} + highlightLines={lineSelection + ? { + start: Math.min(lineSelection.start, lineSelection.end), + end: Math.max(lineSelection.start, lineSelection.end), + } + : undefined} + lineNumbersConfig={{ + domEventHandlers: { + mousedown: (view, line, event) => { + if (!(event instanceof MouseEvent)) return false; + if (event.button !== 0) return false; + event.preventDefault(); + const lineNumber = view.state.doc.lineAt(line.from).number; + + if (isMobile && lineSelection && !event.shiftKey) { + const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); + const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); + setLineSelection({ start, end }); + isSelectingRef.current = false; + selectionStartRef.current = null; + return true; + } + + isSelectingRef.current = true; + selectionStartRef.current = lineNumber; + + if (lineSelection && event.shiftKey) { + const start = Math.min(lineSelection.start, lineNumber); + const end = Math.max(lineSelection.end, lineNumber); + setLineSelection({ start, end }); + } else { + setLineSelection({ start: lineNumber, end: lineNumber }); + } - if (isMobile && lineSelection && !event.shiftKey) { - const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); - const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); - setLineSelection({ start, end }); - isSelectingRef.current = false; - selectionStartRef.current = null; return true; - } - - isSelectingRef.current = true; - selectionStartRef.current = lineNumber; - - if (lineSelection && event.shiftKey) { - const start = Math.min(lineSelection.start, lineNumber); - const end = Math.max(lineSelection.end, lineNumber); - setLineSelection({ start, end }); - } else { - setLineSelection({ start: lineNumber, end: lineNumber }); - } - - return true; - }, - mouseover: (view, line, event) => { - if (!(event instanceof MouseEvent)) return false; - if (event.buttons !== 1) return false; - if (!isSelectingRef.current || selectionStartRef.current === null) return false; + }, + mouseover: (view, line, event) => { + if (!(event instanceof MouseEvent)) return false; + if (event.buttons !== 1) return false; + if (!isSelectingRef.current || selectionStartRef.current === null) return false; const lineNumber = view.state.doc.lineAt(line.from).number; const start = Math.min(selectionStartRef.current, lineNumber); const end = Math.max(selectionStartRef.current, lineNumber); @@ -606,6 +667,8 @@ export const PlanView: React.FC = () => { }, }} /> + {renderSavedComments()} +
)}
diff --git a/packages/ui/src/lib/messages/inlineComments.ts b/packages/ui/src/lib/messages/inlineComments.ts new file mode 100644 index 00000000..e2cd8801 --- /dev/null +++ b/packages/ui/src/lib/messages/inlineComments.ts @@ -0,0 +1,58 @@ +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; + +/** + * Format a single inline comment draft into the standard message format + * used by diff, plan, and file viewers + */ +export function formatInlineCommentDraft(draft: InlineCommentDraft): string { + const { fileLabel, startLine, endLine, side, language, code, text } = draft; + + // Diff format includes side (original/modified) + if (draft.source === 'diff' && side) { + return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`; + } + + // Plan and file format (no side) + return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`; +} + +/** + * Format multiple inline comment drafts into a single string + * with each comment separated by a blank line + */ +export function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string { + if (drafts.length === 0) return ''; + + return drafts.map(formatInlineCommentDraft).join('\n\n'); +} + +/** + * Append inline comment drafts to an existing message text + * If the text is empty, returns just the formatted comments + * Otherwise, appends comments after a blank line separator + */ +export function appendInlineComments(text: string, drafts: InlineCommentDraft[]): string { + if (drafts.length === 0) return text; + + const formattedComments = formatInlineCommentDrafts(drafts); + + if (!text.trim()) { + return formattedComments; + } + + return `${text}\n\n${formattedComments}`; +} + +/** + * Check if a message text contains inline comments (for validation purposes) + */ +export function hasInlineComments(text: string): boolean { + return text.includes('Comment on `') && text.includes('```'); +} + +/** + * Extract the file label from a draft for display purposes + */ +export function getDraftDisplayLabel(draft: InlineCommentDraft): string { + return `${draft.fileLabel}:${draft.startLine}-${draft.endLine}`; +} diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts new file mode 100644 index 00000000..e78d11d3 --- /dev/null +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -0,0 +1,130 @@ +import { create } from 'zustand'; +import { devtools, persist, createJSONStorage } from 'zustand/middleware'; +import { getSafeStorage } from './utils/safeStorage'; + +export type InlineCommentSource = 'diff' | 'plan' | 'file'; + +export interface InlineCommentDraft { + id: string; + sessionKey: string; // sessionId or 'draft' for new sessions + source: InlineCommentSource; + fileLabel: string; // filename or 'plan' + startLine: number; + endLine: number; + side?: 'original' | 'modified'; // diff only + code: string; + language: string; + text: string; + createdAt: number; +} + +interface InlineCommentDraftState { + drafts: Record; // sessionKey -> drafts +} + +interface InlineCommentDraftActions { + addDraft: (draft: Omit) => void; + removeDraft: (sessionKey: string, draftId: string) => void; + clearDrafts: (sessionKey: string) => void; + getDrafts: (sessionKey: string) => InlineCommentDraft[]; + consumeDrafts: (sessionKey: string) => InlineCommentDraft[]; + getDraftCount: (sessionKey: string) => number; + hasDrafts: (sessionKey: string) => boolean; +} + +type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions; + +export const useInlineCommentDraftStore = create()( + devtools( + persist( + (set, get) => ({ + drafts: {}, + + addDraft: (draft) => { + const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const newDraft: InlineCommentDraft = { + ...draft, + id, + createdAt: Date.now(), + }; + + set((state) => { + const currentDrafts = state.drafts[draft.sessionKey] ?? []; + return { + drafts: { + ...state.drafts, + [draft.sessionKey]: [...currentDrafts, newDraft], + }, + }; + }); + + return id; + }, + + removeDraft: (sessionKey, draftId) => { + set((state) => { + const currentDrafts = state.drafts[sessionKey] ?? []; + const newDrafts = currentDrafts.filter((d) => d.id !== draftId); + + if (newDrafts.length === 0) { + const { [sessionKey]: _removed, ...rest } = state.drafts; + void _removed; + return { drafts: rest }; + } + + return { + drafts: { + ...state.drafts, + [sessionKey]: newDrafts, + }, + }; + }); + }, + + clearDrafts: (sessionKey) => { + set((state) => { + const { [sessionKey]: _removed, ...rest } = state.drafts; + void _removed; + return { drafts: rest }; + }); + }, + + getDrafts: (sessionKey) => { + return get().drafts[sessionKey] ?? []; + }, + + consumeDrafts: (sessionKey) => { + const drafts = get().drafts[sessionKey] ?? []; + if (drafts.length === 0) return []; + + // Sort by creation time to maintain order + const sortedDrafts = [...drafts].sort((a, b) => a.createdAt - b.createdAt); + + // Clear drafts after consuming + set((state) => { + const { [sessionKey]: _removed, ...rest } = state.drafts; + void _removed; + return { drafts: rest }; + }); + + return sortedDrafts; + }, + + getDraftCount: (sessionKey) => { + return (get().drafts[sessionKey] ?? []).length; + }, + + hasDrafts: (sessionKey) => { + return (get().drafts[sessionKey] ?? []).length > 0; + }, + }), + { + name: 'openchamber-inline-comment-drafts', + storage: createJSONStorage(() => getSafeStorage()), + } + ), + { name: 'inline-comment-draft-store' } + ) +); + +export default useInlineCommentDraftStore;