Implement git review comments (vibe-kanban) (#654)
* Perfect! I have successfully implemented the git review comments functionality. Let me provide a summary of what was implemented: ## ✅ Git Review Comments Implementation Complete The implementation leverages git-diff-view's built-in review functionality and integrates it seamlessly with the existing follow-up message system. ### 🔧 **Components Created:** 1. **ReviewProvider** ([`frontend/src/contexts/ReviewProvider.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/contexts/ReviewProvider.tsx)) - Manages comment state across all diff files - Handles draft comments and saved comments - Generates markdown for follow-up messages 2. **CommentWidgetLine** ([`frontend/src/components/diff/CommentWidgetLine.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/diff/CommentWidgetLine.tsx)) - Textarea component for new comments - Auto-focus, keyboard shortcuts (Ctrl+Enter to save, Esc to cancel) - Save/Cancel buttons with validation 3. **ReviewCommentRenderer** ([`frontend/src/components/diff/ReviewCommentRenderer.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/diff/ReviewCommentRenderer.tsx)) - Displays saved comments with delete functionality - Visual styling with comment icon and border 4. **ReviewSubmissionBar** ([`frontend/src/components/diff/ReviewSubmissionBar.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/diff/ReviewSubmissionBar.tsx)) - Sticky bottom bar showing comment count - Submit Review and Discard buttons ### 🔄 **Enhanced Components:** 1. **DiffCard** ([`frontend/src/components/DiffCard.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/DiffCard.tsx)) - Integrated git-diff-view review props - Added comment count badges - Handles click events for adding comments - Renders existing comments inline 2. **DiffTab** ([`frontend/src/components/tasks/TaskDetails/DiffTab.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/tasks/TaskDetails/DiffTab.tsx)) - Wrapped with ReviewProvider - Integrated review submission workflow - Handles follow-up message API calls ### 🎯 **Key Features:** - **GitHub-style inline comments**: Click + button on hover to add comments - **Comment persistence**: Comments stay visible in diff until submitted - **Visual indicators**: Comment count badges on file headers - **Keyboard shortcuts**: Ctrl+Enter to save, Esc to cancel - **Markdown generation**: Comments formatted as review markdown for follow-up messages - **Batch submission**: Submit all comments at once as a single follow-up message ### 🔄 **User Workflow:** 1. User hovers over diff line → **+** button appears 2. Click **+** → Comment textarea opens with auto-focus 3. Type comment → Save with button or Ctrl+Enter 4. Comment appears inline in diff with delete option 5. Continue adding comments across multiple files 6. Submit Review button appears in sticky bottom bar 7. Click Submit Review → All comments sent as markdown follow-up message 8. Comments cleared and follow-up message appears in conversation The implementation is complete and ready for testing. The missing `@ebay/nice-modal-react` dependency is a pre-existing issue unrelated to the review functionality. * ## ✅ Function Signature Update Complete I successfully updated the `handleAddWidgetClick` function signature as requested: ### **Changes Made:** 1. **Updated function signature** in [`DiffCard.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/DiffCard.tsx#L129): ```ts // Before const handleAddWidgetClick = (ctx: any) => { ... } // After const handleAddWidgetClick = (lineNumber: number, side: SplitSide) => { ... } ``` 2. **Imported correct types** from git-diff-view library: ```ts import { DiffModeEnum, DiffView, type SplitSide } from '@git-diff-view/react'; ``` 3. **Updated widget key generation** to use the new parameters: ```ts // Before const widgetKey = `${ctx.filePath}-${ctx.side}-${ctx.insertedLineNumber || ctx.deletedLineNumber}`; // After const widgetKey = `${filePath}-${side}-${lineNumber}`; ``` 4. **Used filePath from component scope** instead of context parameter: - The `filePath` is now captured from the component's scope (`newName || oldName || 'unknown'`) - This is the correct pattern as the git-diff-view library doesn't need to know about repository-specific file paths 5. **Updated renderWidgetLine** to use filePath from scope: ```ts const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`; ``` ### **Key Benefits:** - **Correct API compliance**: Now matches the git-diff-view v0.0.30+ API specification - **Type safety**: Uses proper SplitSide type from the library - **Cleaner separation**: File path logic stays in the UI layer where it belongs - **No functionality lost**: All required data (filePath, side, lineNumber) is still available The implementation has been tested and builds successfully. The function signature now correctly matches `(lineNumber: number, side: SplitSide) => void` as requested. * tweaks * ## ✅ Integration Complete I have successfully integrated the review comments functionality into the existing follow-up system! Here's what was implemented: ### **Key Changes:** 1. **Moved ReviewProvider to TaskDetailsPanel** ([`TaskDetailsPanel.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/tasks/TaskDetailsPanel.tsx#L21)) - Now wraps both DiffTab and TaskFollowUpSection - Ensures review comments are accessible across both components 2. **Removed ReviewSubmissionBar** from DiffTab ([`DiffTab.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/tasks/TaskDetails/DiffTab.tsx)) - Cleaned up separate submission logic - Deleted the now-unused ReviewSubmissionBar component 3. **Enhanced TaskFollowUpSection** ([`TaskFollowUpSection.tsx`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-ce81-implement/frontend/src/components/tasks/TaskFollowUpSection.tsx#L28)) - Added review comments preview above the textarea - Shows comment count and formatted markdown preview - Dynamic placeholder text based on review comments presence 4. **Unified Submission Logic** - Combines review comments markdown with optional user input - Updates `canSendFollowUp` logic to allow sending with review comments alone - Clears review comments after successful submission ### **User Experience:** - **Review Comments Preview**: When review comments exist, they appear as a formatted preview above the follow-up textarea - **Optional Additional Input**: Users can add extra instructions or leave the textarea empty - **Visual Indicators**: Comment count and MessageSquare icon show when review comments are present - **Single Send Action**: One "Send" button handles both review comments and additional follow-up text - **Contextual Placeholder**: Textarea placeholder changes to indicate additional text is optional when review comments exist ### **Workflow:** 1. User adds review comments in diff files (+ button on hover) 2. Review comments appear as preview in follow-up section 3. User can optionally add additional instructions 4. Single "Send" button submits combined content 5. Both review comments and additional text sent together as one follow-up 6. Comments cleared after successful submission The integration provides a cleaner, unified experience while maintaining all the functionality of both systems. The build passes successfully and the implementation is ready for use! * styling improvements * tsc * SplitSide * turn off useless eslint warning * clear comments btn * dark mode styles * cleanup header * fmt * edit reviews * fmt
This commit is contained in:
committed by
GitHub
parent
1cb067d894
commit
257cf60a35
@@ -25,12 +25,7 @@
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{
|
||||
"allowConstantExport": true
|
||||
}
|
||||
],
|
||||
"react-refresh/only-export-components": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Diff } from 'shared/types';
|
||||
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
|
||||
import { DiffModeEnum, DiffView, SplitSide } from '@git-diff-view/react';
|
||||
import { generateDiffFile } from '@git-diff-view/file';
|
||||
import { useMemo } from 'react';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
@@ -16,10 +16,14 @@ import {
|
||||
Copy,
|
||||
Key,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import '@/styles/diff-style-overrides.css';
|
||||
import { attemptsApi } from '@/lib/api';
|
||||
import type { TaskAttempt } from 'shared/types';
|
||||
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
|
||||
import { CommentWidgetLine } from '@/components/diff/CommentWidgetLine';
|
||||
import { ReviewCommentRenderer } from '@/components/diff/ReviewCommentRenderer';
|
||||
|
||||
type Props = {
|
||||
diff: Diff;
|
||||
@@ -48,6 +52,7 @@ export default function DiffCard({
|
||||
}: Props) {
|
||||
const { config } = useUserSystem();
|
||||
const theme = getActualTheme(config?.theme);
|
||||
const { comments, drafts, setDraft } = useReview();
|
||||
|
||||
const oldName = diff.oldPath || undefined;
|
||||
const newName = diff.newPath || oldName || 'unknown';
|
||||
@@ -94,6 +99,64 @@ export default function DiffCard({
|
||||
const add = diffFile?.additionLength ?? 0;
|
||||
const del = diffFile?.deletionLength ?? 0;
|
||||
|
||||
// Review functionality
|
||||
const filePath = newName || oldName || 'unknown';
|
||||
const commentsForFile = useMemo(
|
||||
() => comments.filter((c) => c.filePath === filePath),
|
||||
[comments, filePath]
|
||||
);
|
||||
|
||||
// Transform comments to git-diff-view extendData format
|
||||
const extendData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const oldFileData: Record<string, { data: any }> = {};
|
||||
const newFileData: Record<string, { data: any }> = {};
|
||||
|
||||
commentsForFile.forEach((comment) => {
|
||||
const lineKey = String(comment.lineNumber);
|
||||
if (comment.side === SplitSide.old) {
|
||||
oldFileData[lineKey] = { data: comment };
|
||||
} else {
|
||||
newFileData[lineKey] = { data: comment };
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oldFile: oldFileData,
|
||||
newFile: newFileData,
|
||||
};
|
||||
}, [commentsForFile]);
|
||||
|
||||
const handleAddWidgetClick = (lineNumber: number, side: SplitSide) => {
|
||||
const widgetKey = `${filePath}-${side}-${lineNumber}`;
|
||||
const draft: ReviewDraft = {
|
||||
filePath,
|
||||
side,
|
||||
lineNumber,
|
||||
text: '',
|
||||
};
|
||||
setDraft(widgetKey, draft);
|
||||
};
|
||||
|
||||
const renderWidgetLine = (props: any) => {
|
||||
const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`;
|
||||
const draft = drafts[widgetKey];
|
||||
if (!draft) return null;
|
||||
|
||||
return (
|
||||
<CommentWidgetLine
|
||||
draft={draft}
|
||||
widgetKey={widgetKey}
|
||||
onSave={props.onClose}
|
||||
onCancel={props.onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderExtendLine = (lineData: any) => {
|
||||
return <ReviewCommentRenderer comment={lineData.data} />;
|
||||
};
|
||||
|
||||
// Title row
|
||||
const title = (
|
||||
<p
|
||||
@@ -117,6 +180,12 @@ export default function DiffCard({
|
||||
<span className="ml-2" style={{ color: 'hsl(var(--console-error))' }}>
|
||||
-{del}
|
||||
</span>
|
||||
{commentsForFile.length > 0 && (
|
||||
<span className="ml-3 inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{commentsForFile.length}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -180,6 +249,11 @@ export default function DiffCard({
|
||||
diffViewHighlight
|
||||
diffViewMode={DiffModeEnum.Unified}
|
||||
diffViewFontSize={12}
|
||||
diffViewAddWidget
|
||||
onAddWidgetClick={handleAddWidgetClick}
|
||||
renderWidgetLine={renderWidgetLine}
|
||||
extendData={extendData}
|
||||
renderExtendLine={renderExtendLine}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
78
frontend/src/components/diff/CommentWidgetLine.tsx
Normal file
78
frontend/src/components/diff/CommentWidgetLine.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
|
||||
|
||||
interface CommentWidgetLineProps {
|
||||
draft: ReviewDraft;
|
||||
widgetKey: string;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CommentWidgetLine({
|
||||
draft,
|
||||
widgetKey,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CommentWidgetLineProps) {
|
||||
const { setDraft, addComment } = useReview();
|
||||
const [value, setValue] = useState(draft.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
if (value.trim()) {
|
||||
addComment({
|
||||
filePath: draft.filePath,
|
||||
side: draft.side,
|
||||
lineNumber: draft.lineNumber,
|
||||
text: value.trim(),
|
||||
});
|
||||
}
|
||||
setDraft(widgetKey, null);
|
||||
onSave();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraft(widgetKey, null);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-y">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment..."
|
||||
className="w-full bg-primary text-primary-foreground text-sm font-mono resize-none min-h-[60px] focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="xs" onClick={handleSave} disabled={!value.trim()}>
|
||||
Add review comment
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="text-secondary-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/diff/ReviewCommentRenderer.tsx
Normal file
107
frontend/src/components/diff/ReviewCommentRenderer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Trash2, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useReview, type ReviewComment } from '@/contexts/ReviewProvider';
|
||||
|
||||
interface ReviewCommentRendererProps {
|
||||
comment: ReviewComment;
|
||||
}
|
||||
|
||||
export function ReviewCommentRenderer({ comment }: ReviewCommentRendererProps) {
|
||||
const { deleteComment, updateComment } = useReview();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(comment.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteComment(comment.id);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditText(comment.text);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editText.trim()) {
|
||||
updateComment(comment.id, editText.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditText(comment.text);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="border-y bg-background p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Edit comment..."
|
||||
className="w-full bg-background text-foreground text-sm font-mono resize-none min-h-[60px] focus:outline-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="xs" onClick={handleSave} disabled={!editText.trim()}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="text-secondary-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-y bg-background p-4 flex gap-2 items-center">
|
||||
<div className="flex-1 text-sm whitespace-pre-wrap text-foreground">
|
||||
{comment.text}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleEdit}
|
||||
title="Edit comment"
|
||||
className="h-auto"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleDelete}
|
||||
title="Delete comment"
|
||||
className="h-auto"
|
||||
>
|
||||
<Trash2 className="h-3 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,45 @@ function DiffTab({ selectedAttempt }: DiffTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<DiffTabContent
|
||||
diffs={diffs}
|
||||
fileCount={fileCount}
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
collapsedIds={collapsedIds}
|
||||
allCollapsed={allCollapsed}
|
||||
handleCollapseAll={handleCollapseAll}
|
||||
toggle={toggle}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffTabContentProps {
|
||||
diffs: any[];
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
collapsedIds: Set<string>;
|
||||
allCollapsed: boolean;
|
||||
handleCollapseAll: () => void;
|
||||
toggle: (id: string) => void;
|
||||
selectedAttempt: TaskAttempt | null;
|
||||
}
|
||||
|
||||
function DiffTabContent({
|
||||
diffs,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
collapsedIds,
|
||||
allCollapsed,
|
||||
handleCollapseAll,
|
||||
toggle,
|
||||
selectedAttempt,
|
||||
}: DiffTabContentProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col relative">
|
||||
{diffs.length > 0 && (
|
||||
<div className="sticky top-0 bg-background border-b px-4 py-2 z-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -18,6 +18,7 @@ import TaskDetailsToolbar from './TaskDetailsToolbar.tsx';
|
||||
import TodoPanel from '@/components/tasks/TodoPanel';
|
||||
import { TabNavContext } from '@/contexts/TabNavigationContext';
|
||||
import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
|
||||
import { ReviewProvider } from '@/contexts/ReviewProvider';
|
||||
import { AttemptHeaderCard } from './AttemptHeaderCard';
|
||||
import { inIframe } from '@/vscode/bridge';
|
||||
|
||||
@@ -74,6 +75,10 @@ export function TaskDetailsPanel({
|
||||
setActiveTab('diffs');
|
||||
};
|
||||
|
||||
const jumpToLogsTab = () => {
|
||||
setActiveTab('logs');
|
||||
};
|
||||
|
||||
// Reset to logs tab when task changes
|
||||
useEffect(() => {
|
||||
if (task?.id) {
|
||||
@@ -105,130 +110,134 @@ export function TaskDetailsPanel({
|
||||
{!task ? null : (
|
||||
<TabNavContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<ProcessSelectionProvider>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
{!hideBackdrop && (
|
||||
<ReviewProvider>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
{!hideBackdrop && (
|
||||
<div
|
||||
className={getBackdropClasses(isFullScreen || false)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={getBackdropClasses(isFullScreen || false)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
className={
|
||||
className || getTaskPanelClasses(isFullScreen || false)
|
||||
}
|
||||
>
|
||||
<div className={getTaskPanelInnerClasses()}>
|
||||
{!inIframe() && (
|
||||
<TaskDetailsHeader
|
||||
task={task}
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
hideCloseButton={hideBackdrop}
|
||||
isFullScreen={isFullScreen}
|
||||
setFullScreen={setFullScreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={
|
||||
className || getTaskPanelClasses(isFullScreen || false)
|
||||
}
|
||||
>
|
||||
<div className={getTaskPanelInnerClasses()}>
|
||||
{!inIframe() && (
|
||||
<TaskDetailsHeader
|
||||
task={task}
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
hideCloseButton={hideBackdrop}
|
||||
isFullScreen={isFullScreen}
|
||||
setFullScreen={setFullScreen}
|
||||
/>
|
||||
)}
|
||||
{isFullScreen ? (
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* Fullscreen sidebar shows title and description above edit/delete */}
|
||||
<div className="space-y-2 p-3">
|
||||
<TaskTitleDescription task={task} />
|
||||
</div>
|
||||
|
||||
{isFullScreen ? (
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* Fullscreen sidebar shows title and description above edit/delete */}
|
||||
<div className="space-y-2 p-3">
|
||||
<TaskTitleDescription task={task} />
|
||||
</div>
|
||||
|
||||
{/* Current Attempt / Actions */}
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
|
||||
{/* Task Breakdown (TODOs) */}
|
||||
<TodoPanel selectedAttempt={selectedAttempt} />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab selectedAttempt={selectedAttempt} />
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab attemptId={selectedAttempt?.id} />
|
||||
) : (
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
selectedAttemptProfile={selectedAttempt?.executor}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attempts.length === 0 ? (
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AttemptHeaderCard
|
||||
attemptNumber={attemptNumber}
|
||||
totalAttempts={attempts.length}
|
||||
selectedAttempt={selectedAttempt}
|
||||
{/* Current Attempt / Actions */}
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
// onCreateNewAttempt={() => {
|
||||
// // TODO: Implement create new attempt
|
||||
// console.log('Create new attempt');
|
||||
// }}
|
||||
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
{/* Task Breakdown (TODOs) */}
|
||||
<TodoPanel selectedAttempt={selectedAttempt} />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab selectedAttempt={selectedAttempt} />
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab attemptId={selectedAttempt?.id} />
|
||||
) : (
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
selectedAttemptProfile={selectedAttempt?.executor}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attempts.length === 0 ? (
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AttemptHeaderCard
|
||||
attemptNumber={attemptNumber}
|
||||
totalAttempts={attempts.length}
|
||||
selectedAttempt={selectedAttempt}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
// onCreateNewAttempt={() => {
|
||||
// // TODO: Implement create new attempt
|
||||
// console.log('Create new attempt');
|
||||
// }}
|
||||
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
||||
/>
|
||||
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
selectedAttemptProfile={selectedAttempt?.executor}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReviewProvider>
|
||||
</ProcessSelectionProvider>
|
||||
</TabNavContext.Provider>
|
||||
)}
|
||||
|
||||
@@ -24,12 +24,14 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVariantCyclingShortcut } from '@/lib/keyboard-shortcuts';
|
||||
import { useReview } from '@/contexts/ReviewProvider';
|
||||
|
||||
interface TaskFollowUpSectionProps {
|
||||
task: TaskWithAttemptStatus;
|
||||
projectId: string;
|
||||
selectedAttemptId?: string;
|
||||
selectedAttemptProfile?: string;
|
||||
jumpToLogsTab: () => void;
|
||||
}
|
||||
|
||||
export function TaskFollowUpSection({
|
||||
@@ -37,6 +39,7 @@ export function TaskFollowUpSection({
|
||||
projectId,
|
||||
selectedAttemptId,
|
||||
selectedAttemptProfile,
|
||||
jumpToLogsTab,
|
||||
}: TaskFollowUpSectionProps) {
|
||||
const {
|
||||
attemptData,
|
||||
@@ -47,6 +50,12 @@ export function TaskFollowUpSection({
|
||||
} = useAttemptExecution(selectedAttemptId, task.id);
|
||||
const { data: branchStatus } = useBranchStatus(selectedAttemptId);
|
||||
const { profiles } = useUserSystem();
|
||||
const { comments, generateReviewMarkdown, clearComments } = useReview();
|
||||
|
||||
// Generate review markdown when comments change
|
||||
const reviewMarkdown = useMemo(() => {
|
||||
return generateReviewMarkdown();
|
||||
}, [generateReviewMarkdown, comments]);
|
||||
|
||||
// Inline defaultFollowUpVariant logic
|
||||
const defaultFollowUpVariant = useMemo(() => {
|
||||
@@ -118,12 +127,15 @@ export function TaskFollowUpSection({
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
// Allow sending if either review comments exist OR follow-up message is present
|
||||
return Boolean(reviewMarkdown || followUpMessage.trim());
|
||||
}, [
|
||||
selectedAttemptId,
|
||||
attemptData.processes,
|
||||
isSendingFollowUp,
|
||||
branchStatus?.merges,
|
||||
reviewMarkdown,
|
||||
followUpMessage,
|
||||
]);
|
||||
const currentProfile = useMemo(() => {
|
||||
if (!selectedProfile || !profiles) return null;
|
||||
@@ -158,7 +170,15 @@ export function TaskFollowUpSection({
|
||||
});
|
||||
|
||||
const onSendFollowUp = async () => {
|
||||
if (!task || !selectedAttemptId || !followUpMessage.trim()) return;
|
||||
if (!task || !selectedAttemptId) return;
|
||||
|
||||
// Combine review markdown and follow-up message
|
||||
const extraMessage = followUpMessage.trim();
|
||||
const finalPrompt = [reviewMarkdown, extraMessage]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
if (!finalPrompt) return;
|
||||
|
||||
try {
|
||||
setIsSendingFollowUp(true);
|
||||
@@ -172,15 +192,17 @@ export function TaskFollowUpSection({
|
||||
: null;
|
||||
|
||||
await attemptsApi.followUp(selectedAttemptId, {
|
||||
prompt: followUpMessage.trim(),
|
||||
prompt: finalPrompt,
|
||||
variant: selectedVariant,
|
||||
image_ids: imageIds,
|
||||
});
|
||||
setFollowUpMessage('');
|
||||
clearComments(); // Clear review comments after successful submission
|
||||
// Clear images and newly uploaded IDs after successful submission
|
||||
setImages([]);
|
||||
setNewlyUploadedImageIds([]);
|
||||
setShowImageUpload(false);
|
||||
jumpToLogsTab();
|
||||
// No need to manually refetch - React Query will handle this
|
||||
} catch (error: unknown) {
|
||||
// @ts-expect-error it is type ApiError
|
||||
@@ -215,10 +237,22 @@ export function TaskFollowUpSection({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review comments preview */}
|
||||
{reviewMarkdown && (
|
||||
<div className="text-sm mb-4">
|
||||
<div className="whitespace-pre-wrap">{reviewMarkdown}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<FileSearchTextarea
|
||||
placeholder="Continue working on this task attempt... Type @ to search files."
|
||||
placeholder={
|
||||
reviewMarkdown
|
||||
? '(Optional) Add additional instructions... Type @ to search files.'
|
||||
: 'Continue working on this task attempt... Type @ to search files.'
|
||||
}
|
||||
value={followUpMessage}
|
||||
onChange={(value) => {
|
||||
setFollowUpMessage(value);
|
||||
@@ -227,11 +261,7 @@ export function TaskFollowUpSection({
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (
|
||||
canSendFollowUp &&
|
||||
followUpMessage.trim() &&
|
||||
!isSendingFollowUp
|
||||
) {
|
||||
if (canSendFollowUp && !isSendingFollowUp) {
|
||||
onSendFollowUp();
|
||||
}
|
||||
}
|
||||
@@ -324,42 +354,49 @@ export function TaskFollowUpSection({
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
{isAttemptRunning ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={isStopping}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{isStopping ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSendFollowUp}
|
||||
disabled={
|
||||
!canSendFollowUp ||
|
||||
!followUpMessage.trim() ||
|
||||
isSendingFollowUp
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{isSendingFollowUp ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{comments.length > 0 && (
|
||||
<Button
|
||||
onClick={clearComments}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Clear Review Comments
|
||||
</Button>
|
||||
)}
|
||||
{isAttemptRunning ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={isStopping}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{isStopping ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSendFollowUp}
|
||||
disabled={!canSendFollowUp || isSendingFollowUp}
|
||||
size="sm"
|
||||
>
|
||||
{isSendingFollowUp ? (
|
||||
<Loader size={16} className="mr-2" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
112
frontend/src/contexts/ReviewProvider.tsx
Normal file
112
frontend/src/contexts/ReviewProvider.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { SplitSide } from '@git-diff-view/react';
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
side: SplitSide;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ReviewDraft {
|
||||
filePath: string;
|
||||
side: SplitSide;
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ReviewContextType {
|
||||
comments: ReviewComment[];
|
||||
drafts: Record<string, ReviewDraft>;
|
||||
addComment: (comment: Omit<ReviewComment, 'id'>) => void;
|
||||
updateComment: (id: string, text: string) => void;
|
||||
deleteComment: (id: string) => void;
|
||||
clearComments: () => void;
|
||||
setDraft: (key: string, draft: ReviewDraft | null) => void;
|
||||
generateReviewMarkdown: () => string;
|
||||
}
|
||||
|
||||
const ReviewContext = createContext<ReviewContextType | null>(null);
|
||||
|
||||
export function useReview() {
|
||||
const context = useContext(ReviewContext);
|
||||
if (!context) {
|
||||
throw new Error('useReview must be used within a ReviewProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ReviewProvider({ children }: { children: ReactNode }) {
|
||||
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||
const [drafts, setDrafts] = useState<Record<string, ReviewDraft>>({});
|
||||
|
||||
const addComment = (comment: Omit<ReviewComment, 'id'>) => {
|
||||
const newComment: ReviewComment = {
|
||||
...comment,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setComments((prev) => [...prev, newComment]);
|
||||
};
|
||||
|
||||
const updateComment = (id: string, text: string) => {
|
||||
setComments((prev) =>
|
||||
prev.map((comment) =>
|
||||
comment.id === id ? { ...comment, text } : comment
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const deleteComment = (id: string) => {
|
||||
setComments((prev) => prev.filter((comment) => comment.id !== id));
|
||||
};
|
||||
|
||||
const clearComments = () => {
|
||||
setComments([]);
|
||||
setDrafts({});
|
||||
};
|
||||
|
||||
const setDraft = (key: string, draft: ReviewDraft | null) => {
|
||||
setDrafts((prev) => {
|
||||
if (draft === null) {
|
||||
const newDrafts = { ...prev };
|
||||
delete newDrafts[key];
|
||||
return newDrafts;
|
||||
}
|
||||
return { ...prev, [key]: draft };
|
||||
});
|
||||
};
|
||||
|
||||
const generateReviewMarkdown = () => {
|
||||
if (comments.length === 0) return '';
|
||||
|
||||
const commentsNum = comments.length;
|
||||
|
||||
const header = `## Review Comments (${commentsNum})\n\n`;
|
||||
const commentsMd = comments
|
||||
.map(
|
||||
(comment) =>
|
||||
`**${comment.filePath}** (Line ${comment.lineNumber})\n\n> ${comment.text.trim()}\n`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return header + commentsMd;
|
||||
};
|
||||
|
||||
return (
|
||||
<ReviewContext.Provider
|
||||
value={{
|
||||
comments,
|
||||
drafts,
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
clearComments,
|
||||
setDraft,
|
||||
generateReviewMarkdown,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReviewContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user