Opencode/daring jackal (#356)
* feat: export inline comment components Expose InlineCommentCard and InlineCommentInput from the comments index Enable importing both components from a single module path * feat: add InlineCommentCard for inline comments with collapse Enable collapsing for long comments with Show more and Show less Provide dropdown menu with edit and delete actions Show file label and line range in the header * feat: add InlineCommentInput component Add InlineCommentInput component for per-line comments Enable Cmd/Ctrl+Enter to save and Escape to cancel Show file label and line range when provided and focus input on mount * feat: render block widgets in CodeMirrorEditor Add blockWidgets prop to configure widgets after lines Render React portals into per-widget containers within the editor Introduce BlockWidget and DOM container mapping to host widgets * feat(PierreDiffViewer): enable inline comment drafting and theming Add inline comment draft management for diff lines Render InlineCommentInput and InlineCommentCard for comments in diff Integrate theme system to support dark and light modes * feat: improve inline comment editing in PlanView Enable auto-scroll for mobile when editing inline comments Cancel edits when clicking outside comment widgets or code gutter Extend save handler to accept explicit range and guard against empty text * feat(pierre-diff-viewer): enable mobile tap-to-extend selection Extend selection on mobile when tapping the same side to include range Show saved annotation cards on all devices * fix(plan): show saved cards on all devices Render saved cards on all devices Ensure saved cards appear for mobile layouts as well * refactor: simplify state usage and remove focus hacks Remove pending focus state and related effects in PlanView and PierreDiffViewer Initialize UI store usage via useUIStore() without destructuring values in PlanView and PierreDiffViewer Align annotation handling to DiffLineAnnotation metadata and include side in lineRange for inline comments
This commit is contained in:
@@ -1,33 +1,26 @@
|
||||
import React from 'react';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
|
||||
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
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 { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
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, RiMoreLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react';
|
||||
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } 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';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -93,11 +86,10 @@ export const PlanView: React.FC = () => {
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
const { inputBarOffset, isKeyboardOpen } = useUIStore();
|
||||
useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
const [editorView, setEditorView] = React.useState<EditorView | null>(null);
|
||||
|
||||
// Inline comment drafts
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
@@ -138,8 +130,6 @@ export const PlanView: React.FC = () => {
|
||||
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = React.useState(false);
|
||||
const commentInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
|
||||
|
||||
@@ -180,37 +170,35 @@ export const PlanView: React.FC = () => {
|
||||
setLineSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, [content]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingFocus || !lineSelection || isMobile) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const input = commentInputRef.current;
|
||||
input?.focus();
|
||||
if (input) {
|
||||
const length = input.value.length;
|
||||
input.setSelectionRange(length, length);
|
||||
}
|
||||
setPendingFocus(false);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [pendingFocus, lineSelection, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lineSelection) return;
|
||||
|
||||
// Auto-scroll input into view on mobile
|
||||
if (isMobile && !editingDraftId) {
|
||||
// We rely on InlineCommentInput doing this now via useEffect
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const commentUI = document.querySelector('[data-comment-ui]');
|
||||
if (commentUI?.contains(target)) return;
|
||||
// Check if click is inside any comment component
|
||||
if (
|
||||
target.closest('[data-comment-card="true"]') ||
|
||||
target.closest('[data-comment-input="true"]') ||
|
||||
target.closest('.oc-block-widget')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('.cm-gutterElement')) return;
|
||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||
|
||||
// If clicking outside while editing, maybe we should save or ask confirmation?
|
||||
// For now, cancel edit
|
||||
setLineSelection(null);
|
||||
setCommentText('');
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -221,7 +209,7 @@ export const PlanView: React.FC = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [lineSelection]);
|
||||
}, [lineSelection, editingDraftId, isMobile]);
|
||||
|
||||
const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => {
|
||||
const lines = text.split('\n');
|
||||
@@ -235,11 +223,12 @@ export const PlanView: React.FC = () => {
|
||||
setCommentText('');
|
||||
setLineSelection(null);
|
||||
setEditingDraftId(null);
|
||||
setPendingFocus(false);
|
||||
}, []);
|
||||
|
||||
const handleSaveComment = React.useCallback(() => {
|
||||
if (!lineSelection || !commentText.trim()) return;
|
||||
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
|
||||
// Use provided range override or fall back to current selection
|
||||
const targetRange = rangeOverride ?? lineSelection;
|
||||
if (!targetRange || !textToSave.trim()) return;
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) {
|
||||
@@ -247,29 +236,29 @@ export const PlanView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = extractSelectedCode(content, lineSelection);
|
||||
const code = extractSelectedCode(content, targetRange);
|
||||
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
||||
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
source: 'plan',
|
||||
fileLabel,
|
||||
startLine: lineSelection.start,
|
||||
endLine: lineSelection.end,
|
||||
startLine: targetRange.start,
|
||||
endLine: targetRange.end,
|
||||
code,
|
||||
language,
|
||||
text: commentText.trim(),
|
||||
text: textToSave.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,7 +267,8 @@ export const PlanView: React.FC = () => {
|
||||
setEditingDraftId(null);
|
||||
|
||||
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||
}, [lineSelection, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
||||
|
||||
|
||||
const editorExtensions = React.useMemo(() => {
|
||||
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||
@@ -387,170 +377,87 @@ export const PlanView: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderCommentUI = () => {
|
||||
if (!lineSelection) return null;
|
||||
return (
|
||||
<div
|
||||
data-comment-ui
|
||||
className="flex flex-col items-center gap-2 px-4"
|
||||
style={{ width: 'min(100vw - 1rem, 42rem)' }}
|
||||
>
|
||||
<div className="w-full rounded-xl border bg-background flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
|
||||
<Textarea
|
||||
ref={commentInputRef}
|
||||
value={commentText}
|
||||
onChange={(e) => {
|
||||
setCommentText(e.target.value);
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto';
|
||||
const lineHeight = 20;
|
||||
const maxHeight = lineHeight * 5 + 8;
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
||||
}}
|
||||
placeholder="Type your comment..."
|
||||
className="min-h-[28px] max-h-[108px] resize-none border-0 px-3 pt-2 pb-1 shadow-none rounded-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent dark:bg-transparent focus-visible:outline-none overflow-y-auto"
|
||||
autoFocus={!isMobile}
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveComment();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 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-2">
|
||||
{!isMobile && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getModifierLabel()}+⏎
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelComment}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{editingDraftId ? 'Save' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render saved comment cards
|
||||
const renderSavedComments = () => {
|
||||
if (mdViewMode === 'preview') return null;
|
||||
if (!editorView) return null;
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
if (mdViewMode === 'preview') return [];
|
||||
|
||||
const sessionKey = getSessionKey();
|
||||
if (!sessionKey) return null;
|
||||
if (!sessionKey) return [];
|
||||
|
||||
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;
|
||||
const widgets: BlockWidgetDef[] = [];
|
||||
|
||||
return createPortal(
|
||||
<div className="absolute inset-0 pointer-events-none z-40">
|
||||
{fileDrafts.map((draft) => {
|
||||
const maxLine = editorView.state.doc.lines;
|
||||
const safeLine = Math.min(Math.max(1, draft.startLine), maxLine);
|
||||
const line = editorView.state.doc.line(safeLine);
|
||||
const top = editorView.lineBlockAt(line.from).top;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
right: '8px',
|
||||
maxWidth: '300px',
|
||||
// Add saved drafts
|
||||
fileDrafts.forEach((draft) => {
|
||||
if (draft.id === editingDraftId) {
|
||||
// Always show edit input (even on mobile)
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `edit-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
fileLabel={fileLabel}
|
||||
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
||||
initialText={commentText}
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
isEditing={true}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Show saved cards on all devices
|
||||
widgets.push({
|
||||
afterLine: draft.endLine,
|
||||
id: `draft-${draft.id}`,
|
||||
content: (
|
||||
<InlineCommentCard
|
||||
draft={draft}
|
||||
onEdit={() => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
||||
setCommentText(draft.text);
|
||||
setEditingDraftId(draft.id);
|
||||
setPendingFocus(true);
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 mr-2" />
|
||||
Edit comment
|
||||
</DropdownMenuItem>
|
||||
<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>,
|
||||
editorView.scrollDOM
|
||||
);
|
||||
};
|
||||
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add new comment input if selecting AND not editing an existing draft
|
||||
if (lineSelection && !editingDraftId) {
|
||||
widgets.push({
|
||||
afterLine: lineSelection.end,
|
||||
id: 'new-comment-input',
|
||||
content: (
|
||||
<InlineCommentInput
|
||||
fileLabel={fileLabel}
|
||||
lineRange={lineSelection}
|
||||
initialText={commentText} // Usually empty for new, unless restored?
|
||||
onSave={handleSaveComment}
|
||||
onCancel={handleCancelComment}
|
||||
isEditing={false}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}, [
|
||||
mdViewMode,
|
||||
getSessionKey,
|
||||
allDrafts,
|
||||
displayPath,
|
||||
editingDraftId,
|
||||
lineSelection,
|
||||
commentText,
|
||||
handleSaveComment,
|
||||
handleCancelComment,
|
||||
removeDraft,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
||||
@@ -636,11 +543,7 @@ export const PlanView: React.FC = () => {
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
['--oc-plan-comment-pad' as string]: lineSelection
|
||||
? (isMobile
|
||||
? 'calc(var(--oc-keyboard-inset, 0px) + 140px)'
|
||||
: '140px')
|
||||
: '0px',
|
||||
['--oc-plan-comment-pad' as string]: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="h-full oc-plan-editor">
|
||||
@@ -669,14 +572,13 @@ export const PlanView: React.FC = () => {
|
||||
readOnly={true}
|
||||
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
||||
extensions={editorExtensions}
|
||||
onViewReady={setEditorView}
|
||||
onViewDestroy={() => setEditorView(null)}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
: undefined}
|
||||
blockWidgets={blockWidgets}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view, line, event) => {
|
||||
@@ -725,7 +627,6 @@ export const PlanView: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{renderSavedComments()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -734,28 +635,6 @@ export const PlanView: React.FC = () => {
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
|
||||
{lineSelection && mdViewMode !== 'preview' && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-50 flex flex-col justify-end"
|
||||
style={{ paddingBottom: isMobile ? 'var(--oc-keyboard-inset, 0px)' : '0px' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto pb-2 transition-none w-full flex justify-center",
|
||||
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
||||
)}
|
||||
style={{
|
||||
marginBottom: isMobile
|
||||
? (!isKeyboardOpen && inputBarOffset > 0 ? `${inputBarOffset}px` : '16px')
|
||||
: '16px'
|
||||
}}
|
||||
data-keyboard-avoid="true"
|
||||
>
|
||||
{renderCommentUI()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user