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:
Louis Knight-Webb
2025-09-08 17:28:19 +01:00
committed by GitHub
parent 1cb067d894
commit 257cf60a35
8 changed files with 612 additions and 162 deletions

View File

@@ -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>
)}

View 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>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>

View 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>
);
}