Introduce inline comment in plan and diff panel (#277)
* feat(chat): support inline comment drafts in chat input Add per-session inline comment draft tracking Append inline drafts to outgoing messages when queuing Consume drafts after sending to clear state * feat: add actions menu to PierreDiffViewer Add a dropdown actions menu in the diff viewer with More and Delete options Enable inline comment drafting support and selection synchronization to prevent loops Replace previous action icon with contextual actions in the header * feat(plan): enable inline comment drafting in PlanView Add inline comment draft support with addDraft, removeDraft and drafts state Compute session key for drafts based on currentSessionId and draft state Prepare dropdown and toast utilities for draft actions in PlanView * feat: add inline comment formatting utilities Introduce formatInlineCommentDraft to render inline comments for diff and plan views Add appendInlineComments to attach comments to existing messages with proper separation Provide hasInlineComments to detect inline comment blocks in text * feat: add inline comment draft store Persist drafts per session key for inline comments Add, remove, and clear drafts with unique IDs per session Consume drafts to move them out of store after submission
This commit is contained in:
@@ -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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
)}
|
||||
<AttachedFilesList />
|
||||
<QueuedMessageChips
|
||||
<QueuedMessageChips
|
||||
onEditMessage={(content) => {
|
||||
setMessage(content);
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
{/* Review comments chip */}
|
||||
{hasDrafts && (
|
||||
<div className="pb-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">Review comments:</span>
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: currentTheme?.colors?.status?.info }}
|
||||
>
|
||||
{draftCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs';
|
||||
import { RiSendPlane2Line } from '@remixicon/react';
|
||||
import { RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -11,14 +11,19 @@ import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
|
||||
interface PierreDiffViewerProps {
|
||||
@@ -132,6 +137,12 @@ const extractSelectedCode = (original: string, modified: string, range: Selected
|
||||
return lines.slice(startLine - 1, endLine).join('\n');
|
||||
};
|
||||
|
||||
const isSameSelection = (left: SelectedLineRange | null, right: SelectedLineRange | null): boolean => {
|
||||
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<PierreDiffViewerProps> = ({
|
||||
original,
|
||||
modified,
|
||||
@@ -160,12 +171,20 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
||||
fallbackDark;
|
||||
|
||||
const setActiveMainTab = useUIStore(state => state.setActiveMainTab);
|
||||
|
||||
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const commentContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs to prevent infinite loops when syncing selection with diff instance
|
||||
const selectionRef = useRef<SelectedLineRange | null>(null);
|
||||
const isApplyingSelectionRef = useRef(false);
|
||||
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(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<PierreDiffViewerProps> = ({
|
||||
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<PierreDiffViewerProps> = ({
|
||||
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<PierreDiffViewerProps> = ({
|
||||
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<PierreDiffViewerProps> = ({
|
||||
};
|
||||
}, [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<PierreDiffViewerProps> = ({
|
||||
|
||||
const instance = new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
||||
diffInstanceRef.current = instance;
|
||||
lastAppliedSelectionRef.current = null;
|
||||
|
||||
const oldFile: FileContents = {
|
||||
name: fileName,
|
||||
@@ -482,10 +514,27 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
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<PierreDiffViewerProps> = ({
|
||||
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 */}
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-1.5">
|
||||
{/* Footer with Cancel and Comment buttons */}
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fileName.split('/').pop()}:{selection.start}-{selection.end}
|
||||
</span>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isMobile && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getModifierLabel()}+⏎
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onTouchEnd={(e) => {
|
||||
// On mobile, handle send via touchend to avoid race with selection clearing
|
||||
if (commentText.trim()) {
|
||||
e.preventDefault();
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// Desktop click handler
|
||||
if (!isMobile) {
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
disabled={!commentText.trim()}
|
||||
className={cn(
|
||||
"h-7 w-7 flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0",
|
||||
commentText.trim() ? "text-primary hover:text-primary" : "opacity-30"
|
||||
)}
|
||||
aria-label="Send comment"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelComment}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RiSendPlane2Line className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSaveComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,6 +625,70 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Render saved comment cards
|
||||
const renderSavedComments = () => {
|
||||
if (drafts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{drafts.map(draft => {
|
||||
// Approximate line placement; shadow DOM prevents direct line querying.
|
||||
const lineHeight = 24;
|
||||
const top = (draft.startLine - 1) * lineHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
right: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border p-2 shadow-md"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
|
||||
{draft.side && ` (${draft.side})`}
|
||||
</div>
|
||||
<div className="text-sm line-clamp-3">{draft.text}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<PierreDiffViewerProps> = ({
|
||||
disableHorizontal={false}
|
||||
fillContainer={true}
|
||||
>
|
||||
<div ref={diffRootRef} className="size-full">
|
||||
<div ref={diffRootRef} className="size-full relative">
|
||||
<div ref={diffContainerRef} className="size-full" />
|
||||
{renderSavedComments()}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
@@ -630,8 +742,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
// Use simple div with overflow-x-auto to avoid nested ScrollableOverlay issues in Chrome
|
||||
return (
|
||||
<div className={cn("relative", "w-full")}>
|
||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible">
|
||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
||||
<div ref={diffContainerRef} className="w-full" />
|
||||
{renderSavedComments()}
|
||||
</div>
|
||||
|
||||
{selection && createPortal(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-1.5">
|
||||
{/* Footer with Cancel and Comment buttons */}
|
||||
<div className="px-2.5 py-1 flex items-center justify-between gap-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Plan:{lineSelection.start}-{lineSelection.end}
|
||||
</span>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isMobile && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getModifierLabel()}+⏎
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onTouchEnd={(e) => {
|
||||
if (commentText.trim()) {
|
||||
e.preventDefault();
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isMobile) {
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
disabled={!commentText.trim()}
|
||||
className={cn(
|
||||
"h-7 w-7 flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0",
|
||||
commentText.trim() ? "text-primary hover:text-primary" : "opacity-30"
|
||||
)}
|
||||
aria-label="Send comment"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelComment}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RiSendPlane2Line className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSaveComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.status?.success,
|
||||
color: currentTheme?.colors?.status?.successForeground,
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{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 (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
right: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border p-2 shadow-md"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{draft.fileLabel}:{draft.startLine}-{draft.endLine}
|
||||
</div>
|
||||
<div className="text-sm line-clamp-3">{draft.text}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-[var(--interactive-hover)] text-muted-foreground"
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-2" />
|
||||
Delete comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -544,54 +604,55 @@ export const PlanView: React.FC = () => {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={() => {
|
||||
// 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;
|
||||
<div className="relative h-full">
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={() => {
|
||||
// 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()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<string, InlineCommentDraft[]>; // sessionKey -> drafts
|
||||
}
|
||||
|
||||
interface InlineCommentDraftActions {
|
||||
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => 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<InlineCommentDraftStore>()(
|
||||
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;
|
||||
Reference in New Issue
Block a user