From 0d0786602fefe788fc288734a983a4fd83e5d58d Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Feb 2026 12:52:51 +0200 Subject: [PATCH] feat: enhance CollapsibleTeamSection and TaskCommentsSection with new features - Added an optional icon prop to CollapsibleTeamSection for improved visual representation. - Introduced hideInput and onReply props in TaskCommentsSection to control comment input visibility and handle reply actions externally. - Updated TaskDetailDialog to utilize new props for better comment management and user interaction. - Enhanced UI components to support these new features, improving overall user experience in task management. --- .../team/CollapsibleTeamSection.tsx | 4 + .../team/dialogs/TaskCommentInput.tsx | 145 +++++++++++++++ .../team/dialogs/TaskCommentsSection.tsx | 170 ++++++++++-------- .../team/dialogs/TaskDetailDialog.tsx | 56 +++++- .../team/review/ChangeReviewDialog.tsx | 1 + .../team/review/CodeMirrorDiffUtils.ts | 39 ++++ .../team/review/ContinuousScrollView.tsx | 23 ++- .../components/team/review/ReviewFileTree.tsx | 77 ++++---- 8 files changed, 383 insertions(+), 132 deletions(-) create mode 100644 src/renderer/components/team/dialogs/TaskCommentInput.tsx diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 7789ab35..83628a1a 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -5,6 +5,8 @@ import { ChevronRight } from 'lucide-react'; interface CollapsibleTeamSectionProps { title: string; + /** Icon rendered before the title text. */ + icon?: React.ReactNode; badge?: string | number; /** Secondary badge (e.g. unread count). Shown next to main badge when defined. */ secondaryBadge?: number; @@ -18,6 +20,7 @@ interface CollapsibleTeamSectionProps { export const CollapsibleTeamSection = ({ title, + icon, badge, secondaryBadge, headerExtra, @@ -43,6 +46,7 @@ export const CollapsibleTeamSection = ({ size={14} className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`} /> + {icon ? {icon} : null} {title} {badge != null && ( void; +} + +export const TaskCommentInput = ({ + teamName, + taskId, + members, + replyTo, + onClearReply, +}: TaskCommentInputProps): React.JSX.Element => { + const addTaskComment = useStore((s) => s.addTaskComment); + const addingComment = useStore((s) => s.addingComment); + + const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + + const mentionSuggestions = useMemo( + () => + members.map((m) => ({ + id: m.name, + name: m.name, + subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, + color: colorMap.get(m.name), + })), + [members, colorMap] + ); + + const trimmed = draft.value.trim(); + const remaining = MAX_COMMENT_LENGTH - trimmed.length; + const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return; + try { + const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed; + await addTaskComment(teamName, taskId, text); + draft.clearDraft(); + onClearReply(); + } catch { + // Error is stored in addCommentError via store + } + }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo, onClearReply]); + + return ( +
+ {replyTo ? ( +
+
+
+ Replying to{' '} + { + const rc = colorMap.get(replyTo.author); + return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; + })(), + }} + > + @{replyTo.author} + +
+
+ {replyTo.text} +
+
+ + + + + Cancel reply + +
+ ) : null} + +
+ void handleSubmit()} + > + + Comment + + } + footerRight={ +
+ {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Draft saved + ) : null} +
+ } + /> +
+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 4b553846..2bc76c03 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -28,6 +28,10 @@ interface TaskCommentsSectionProps { members: ResolvedTeamMember[]; /** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */ hideHeader?: boolean; + /** When true, the comment input area is not rendered (useful when input is rendered externally). */ + hideInput?: boolean; + /** Called when the user clicks Reply on a comment (used when input is rendered externally). */ + onReply?: (author: string, text: string) => void; } export const TaskCommentsSection = ({ @@ -36,6 +40,8 @@ export const TaskCommentsSection = ({ comments, members, hideHeader = false, + hideInput = false, + onReply, }: TaskCommentsSectionProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); @@ -126,14 +132,16 @@ export const TaskCommentsSection = ({ + + Cancel reply + -
- {replyTo.text} -
- - - - - - Cancel reply - - - ) : null} + ) : null} -
- void handleSubmit()} - > - - Comment - - } - footerRight={ -
- {remaining < 200 ? ( - + void handleSubmit()} > - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Draft saved - ) : null} -
- } - /> -
+ + Comment + + } + footerRight={ +
+ {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Draft saved + ) : null} +
+ } + /> + + + )} ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 7ec13ff3..7f508e8b 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; @@ -33,16 +33,21 @@ import { } from '@renderer/utils/memberHelpers'; import { formatDistanceToNow } from 'date-fns'; import { + AlignLeft, ArrowLeftFromLine, ArrowRightFromLine, Clock, FileCode, + FileDiff, Link2, Loader2, + MessageSquare, PenLine, + ScrollText, Trash2, } from 'lucide-react'; +import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -79,6 +84,28 @@ export const TaskDetailDialog = ({ }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; + const [replyTo, setReplyTo] = useState<{ + taskId: string; + author: string; + text: string; + } | null>(null); + const handleReply = useCallback( + (author: string, text: string) => { + if (currentTask) setReplyTo({ taskId: currentTask.id, author, text }); + }, + [currentTask] + ); + const clearReply = useCallback(() => setReplyTo(null), []); + + const handleClose = useCallback(() => { + setReplyTo(null); + onClose(); + }, [onClose]); + + const effectiveReplyTo = + replyTo && replyTo.taskId === currentTask?.id + ? { author: replyTo.author, text: replyTo.text } + : null; useEffect(() => { if (!open || !currentTask) return; @@ -118,13 +145,13 @@ export const TaskDetailDialog = ({ ]); const handleDependencyClick = (taskId: string): void => { - onClose(); + handleClose(); onScrollToTask?.(taskId); }; if (!currentTask) { return ( - !v && onClose()}> + !v && handleClose()}> Task not found @@ -254,7 +281,7 @@ export const TaskDetailDialog = ({ {/* Description */} - + } defaultOpen> {currentTask.description ? (
@@ -265,7 +292,7 @@ export const TaskDetailDialog = ({ {/* Execution Logs — sessions that reference this task */} - + } defaultOpen>
} badge={taskChangesFiles ? taskChangesFiles.length : undefined} defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0} > @@ -296,7 +324,7 @@ export const TaskDetailDialog = ({ type="button" className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]" onClick={() => { - onClose(); + handleClose(); onViewChanges(currentTask.id, file.filePath); }} > @@ -447,6 +475,7 @@ export const TaskDetailDialog = ({ {/* Comments */} } badge={ (currentTask.comments?.length ?? 0) > 0 ? (currentTask.comments?.length ?? 0) @@ -460,9 +489,20 @@ export const TaskDetailDialog = ({ comments={currentTask.comments ?? []} members={members} hideHeader + hideInput + onReply={handleReply} /> + {/* Comment input — always visible outside the collapsible section */} + + {onDeleteTask && currentTask ? ( diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index bc639052..320e697d 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -539,6 +539,7 @@ export const ChangeReviewDialog = ({ fileContentsLoading={fileContentsLoading} viewedSet={viewedSet} editedContents={editedContents} + hunkDecisions={hunkDecisions} fileDecisions={fileDecisions} collapseUnchanged={collapseUnchanged} applying={applying} diff --git a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts index 5b876ae0..aed9aa95 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts +++ b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts @@ -91,4 +91,43 @@ export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) = return { effects: originalDocChangeEffect(tr.startState, tr.changes) }; }); +/** + * Replay persisted per-hunk decisions on a freshly mounted editor. + * Processes chunks in reverse order to preserve earlier chunk positions. + */ +export function replayHunkDecisions( + view: EditorView, + filePath: string, + hunkDecisions: Record +): void { + const result = getChunks(view.state); + if (!result || result.chunks.length === 0) return; + + // Collect decisions that need replaying + const toReplay: { index: number; decision: 'accepted' | 'rejected' }[] = []; + for (let i = 0; i < result.chunks.length; i++) { + const key = `${filePath}:${i}`; + const d = hunkDecisions[key]; + if (d === 'accepted' || d === 'rejected') { + toReplay.push({ index: i, decision: d }); + } + } + + if (toReplay.length === 0) return; + + // Process in reverse order — removing a later chunk doesn't shift earlier positions + for (let i = toReplay.length - 1; i >= 0; i--) { + const { index, decision } = toReplay[i]; + const currentChunks = getChunks(view.state); + if (!currentChunks || index >= currentChunks.chunks.length) continue; + + const chunk = currentChunks.chunks[index]; + if (decision === 'accepted') { + acceptChunk(view, chunk.fromB); + } else { + rejectChunk(view, chunk.fromB); + } + } +} + export { acceptChunk, getChunks, rejectChunk }; diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index f1bab01e..9c604841 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; -import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils'; +import { acceptAllChunks, rejectAllChunks, replayHunkDecisions } from './CodeMirrorDiffUtils'; import { FileSectionDiff } from './FileSectionDiff'; import { FileSectionHeader } from './FileSectionHeader'; import { FileSectionPlaceholder } from './FileSectionPlaceholder'; @@ -18,6 +18,7 @@ interface ContinuousScrollViewProps { fileContentsLoading: Record; viewedSet: Set; editedContents: Record; + hunkDecisions: Record; fileDecisions: Record; collapseUnchanged: boolean; applying: boolean; @@ -48,6 +49,7 @@ export const ContinuousScrollView = ({ fileContentsLoading, viewedSet, editedContents, + hunkDecisions, fileDecisions, collapseUnchanged, applying, @@ -114,10 +116,12 @@ export const ContinuousScrollView = ({ [registerFileSectionRef, registerLazyRef] ); - // Ref to avoid stale closure — fileDecisions changes frequently + // Refs to avoid stale closures — decisions change frequently const fileDecisionsRef = useRef(fileDecisions); + const hunkDecisionsRef = useRef(hunkDecisions); useEffect(() => { fileDecisionsRef.current = fileDecisions; + hunkDecisionsRef.current = hunkDecisions; }); const handleEditorViewReady = useCallback( @@ -125,18 +129,21 @@ export const ContinuousScrollView = ({ if (view) { editorViewMapRef.current.set(filePath, view); - // Sync pre-existing "Accept All" / "Reject All" decisions to newly mounted editors. - // When Accept All runs, store is updated for ALL files, but CM only updates mounted ones. - // Lazily-loaded files mount later and need their CM state synced with the store. - const decision = fileDecisionsRef.current[filePath]; - if (decision === 'accepted' || decision === 'rejected') { + const fileDecision = fileDecisionsRef.current[filePath]; + if (fileDecision === 'accepted' || fileDecision === 'rejected') { + // Sync file-level "Accept All" / "Reject All" decisions requestAnimationFrame(() => { - if (decision === 'accepted') { + if (fileDecision === 'accepted') { acceptAllChunks(view); } else { rejectAllChunks(view); } }); + } else { + // Replay individual per-hunk decisions persisted from previous session + requestAnimationFrame(() => { + replayHunkDecisions(view, filePath, hunkDecisionsRef.current); + }); } } else { editorViewMapRef.current.delete(filePath); diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index 44fb38d7..dfb7a831 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { @@ -7,6 +8,7 @@ import { ChevronRight, Circle, CircleDot, + Eye, File, Folder, FolderOpen, @@ -101,18 +103,36 @@ function getFileStatus( return 'mixed'; } +const statusLabels: Record = { + accepted: 'All changes accepted', + rejected: 'All changes rejected', + mixed: 'Partially reviewed', + pending: 'Pending review', +}; + const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => { - switch (status) { - case 'accepted': - return ; - case 'rejected': - return ; - case 'mixed': - return ; - case 'pending': - default: - return ; - } + const icon = (() => { + switch (status) { + case 'accepted': + return ; + case 'rejected': + return ; + case 'mixed': + return ; + case 'pending': + default: + return ; + } + })(); + + return ( + + + {icon} + + {statusLabels[status]} + + ); }; const TreeItem = ({ @@ -123,8 +143,6 @@ const TreeItem = ({ depth, hunkDecisions, viewedSet, - onMarkViewed, - onUnmarkViewed, collapsedFolders, onToggleFolder, }: { @@ -135,8 +153,6 @@ const TreeItem = ({ depth: number; hunkDecisions: Record; viewedSet?: Set; - onMarkViewed?: (filePath: string) => void; - onUnmarkViewed?: (filePath: string) => void; collapsedFolders: Set; onToggleFolder: (fullPath: string) => void; }): JSX.Element => { @@ -160,22 +176,15 @@ const TreeItem = ({ > - {viewedSet && ( - { - e.stopPropagation(); - if (e.target.checked) { - onMarkViewed?.(node.file!.filePath); - } else { - onUnmarkViewed?.(node.file!.filePath); - } - }} - onClick={(e) => e.stopPropagation()} - className="size-3 shrink-0 rounded border-zinc-600 accent-green-500" - aria-label={`Mark ${node.name} as viewed`} - /> + {viewedSet && viewedSet.has(node.file.filePath) && ( + + + + + + + Viewed + )} @@ -277,8 +284,6 @@ export const ReviewFileTree = ({ selectedFilePath, onSelectFile, viewedSet, - onMarkViewed, - onUnmarkViewed, activeFilePath, }: ReviewFileTreeProps): JSX.Element => { const hunkDecisions = useStore((state) => state.hunkDecisions); @@ -343,8 +348,6 @@ export const ReviewFileTree = ({ depth={0} hunkDecisions={hunkDecisions} viewedSet={viewedSet} - onMarkViewed={onMarkViewed} - onUnmarkViewed={onUnmarkViewed} collapsedFolders={collapsedFolders} onToggleFolder={toggleFolder} />