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.
This commit is contained in:
iliya 2026-02-26 12:52:51 +02:00
parent 7019bf6114
commit 0d0786602f
8 changed files with 383 additions and 132 deletions

View file

@ -5,6 +5,8 @@ import { ChevronRight } from 'lucide-react';
interface CollapsibleTeamSectionProps { interface CollapsibleTeamSectionProps {
title: string; title: string;
/** Icon rendered before the title text. */
icon?: React.ReactNode;
badge?: string | number; badge?: string | number;
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */ /** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
secondaryBadge?: number; secondaryBadge?: number;
@ -18,6 +20,7 @@ interface CollapsibleTeamSectionProps {
export const CollapsibleTeamSection = ({ export const CollapsibleTeamSection = ({
title, title,
icon,
badge, badge,
secondaryBadge, secondaryBadge,
headerExtra, headerExtra,
@ -43,6 +46,7 @@ export const CollapsibleTeamSection = ({
size={14} size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`} className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/> />
{icon ? <span className="shrink-0 text-[var(--color-text-muted)]">{icon}</span> : null}
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span> <span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
{badge != null && ( {badge != null && (
<Badge <Badge

View file

@ -0,0 +1,145 @@
import { useCallback, useMemo } from 'react';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
const MAX_COMMENT_LENGTH = 2000;
interface TaskCommentInputProps {
teamName: string;
taskId: string;
members: ResolvedTeamMember[];
replyTo: { author: string; text: string } | null;
onClearReply: () => 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<MentionSuggestion[]>(
() =>
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 (
<div>
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={onClearReply}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
) : null}
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
</div>
);
};

View file

@ -28,6 +28,10 @@ interface TaskCommentsSectionProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
/** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */ /** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */
hideHeader?: boolean; 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 = ({ export const TaskCommentsSection = ({
@ -36,6 +40,8 @@ export const TaskCommentsSection = ({
comments, comments,
members, members,
hideHeader = false, hideHeader = false,
hideInput = false,
onReply,
}: TaskCommentsSectionProps): React.JSX.Element => { }: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment); const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment); const addingComment = useStore((s) => s.addingComment);
@ -126,14 +132,16 @@ export const TaskCommentsSection = ({
<button <button
type="button" type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100" className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => onClick={() => {
setReplyTo({ const replyText = stripAgentBlocks(
author: comment.author, parseMessageReply(comment.text)?.replyText ?? comment.text
text: stripAgentBlocks( );
parseMessageReply(comment.text)?.replyText ?? comment.text if (onReply) {
), onReply(comment.author, replyText);
}) } else {
} setReplyTo({ author: comment.author, text: replyText });
}
}}
> >
<Reply size={11} /> <Reply size={11} />
Reply Reply
@ -222,80 +230,84 @@ export const TaskCommentsSection = ({
</div> </div>
) : null} ) : null}
{replyTo ? ( {!hideInput && (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"> <>
<div className="min-w-0 flex-1"> {replyTo ? (
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"> <div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
Replying to{' '} <div className="min-w-0 flex-1">
<span <div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
className="font-semibold" Replying to{' '}
style={{ <span
color: (() => { className="font-semibold"
const rc = colorMap.get(replyTo.author); style={{
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; color: (() => {
})(), const rc = colorMap.get(replyTo.author);
}} return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
> })(),
@{replyTo.author} }}
</span> >
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div> </div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]"> ) : null}
{replyTo.text}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
) : null}
<div className="relative"> <div className="relative">
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`} placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
minRows={2} minRows={2}
maxRows={8} maxRows={8}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
disabled={addingComment} disabled={addingComment}
cornerAction={ cornerAction={
<button <button
type="button" type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSubmit} disabled={!canSubmit}
onClick={() => void handleSubmit()} onClick={() => void handleSubmit()}
>
<Send size={12} />
Comment
</button>
}
footerRight={
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
> >
{remaining} chars left <Send size={12} />
</span> Comment
) : null} </button>
{draft.isSaved ? ( }
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> footerRight={
) : null} <div className="flex items-center gap-2">
</div> {remaining < 200 ? (
} <span
/> className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
</div> >
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
</>
)}
</div> </div>
); );
}; };

View file

@ -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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
@ -33,16 +33,21 @@ import {
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import {
AlignLeft,
ArrowLeftFromLine, ArrowLeftFromLine,
ArrowRightFromLine, ArrowRightFromLine,
Clock, Clock,
FileCode, FileCode,
FileDiff,
Link2, Link2,
Loader2, Loader2,
MessageSquare,
PenLine, PenLine,
ScrollText,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { TaskCommentInput } from './TaskCommentInput';
import { TaskCommentsSection } from './TaskCommentsSection'; import { TaskCommentsSection } from './TaskCommentsSection';
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -79,6 +84,28 @@ export const TaskDetailDialog = ({
}: TaskDetailDialogProps): React.JSX.Element => { }: TaskDetailDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null; 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(() => { useEffect(() => {
if (!open || !currentTask) return; if (!open || !currentTask) return;
@ -118,13 +145,13 @@ export const TaskDetailDialog = ({
]); ]);
const handleDependencyClick = (taskId: string): void => { const handleDependencyClick = (taskId: string): void => {
onClose(); handleClose();
onScrollToTask?.(taskId); onScrollToTask?.(taskId);
}; };
if (!currentTask) { if (!currentTask) {
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Task not found</DialogTitle> <DialogTitle>Task not found</DialogTitle>
@ -254,7 +281,7 @@ export const TaskDetailDialog = ({
</div> </div>
{/* Description */} {/* Description */}
<CollapsibleTeamSection title="Description" defaultOpen> <CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
{currentTask.description ? ( {currentTask.description ? (
<div className="max-h-[200px] overflow-y-auto"> <div className="max-h-[200px] overflow-y-auto">
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" /> <MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
@ -265,7 +292,7 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection> </CollapsibleTeamSection>
{/* Execution Logs — sessions that reference this task */} {/* Execution Logs — sessions that reference this task */}
<CollapsibleTeamSection title="Execution Logs" defaultOpen> <CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<MemberLogsTab <MemberLogsTab
teamName={teamName} teamName={teamName}
@ -280,6 +307,7 @@ export const TaskDetailDialog = ({
{isTaskCompleted && onViewChanges ? ( {isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection <CollapsibleTeamSection
title="Changes" title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined} badge={taskChangesFiles ? taskChangesFiles.length : undefined}
defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0} defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0}
> >
@ -296,7 +324,7 @@ export const TaskDetailDialog = ({
type="button" 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)]" 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={() => { onClick={() => {
onClose(); handleClose();
onViewChanges(currentTask.id, file.filePath); onViewChanges(currentTask.id, file.filePath);
}} }}
> >
@ -447,6 +475,7 @@ export const TaskDetailDialog = ({
{/* Comments */} {/* Comments */}
<CollapsibleTeamSection <CollapsibleTeamSection
title="Comments" title="Comments"
icon={<MessageSquare size={14} />}
badge={ badge={
(currentTask.comments?.length ?? 0) > 0 (currentTask.comments?.length ?? 0) > 0
? (currentTask.comments?.length ?? 0) ? (currentTask.comments?.length ?? 0)
@ -460,9 +489,20 @@ export const TaskDetailDialog = ({
comments={currentTask.comments ?? []} comments={currentTask.comments ?? []}
members={members} members={members}
hideHeader hideHeader
hideInput
onReply={handleReply}
/> />
</CollapsibleTeamSection> </CollapsibleTeamSection>
{/* Comment input — always visible outside the collapsible section */}
<TaskCommentInput
teamName={teamName}
taskId={currentTask.id}
members={members}
replyTo={effectiveReplyTo}
onClearReply={clearReply}
/>
<DialogFooter className="flex items-center justify-between sm:justify-between"> <DialogFooter className="flex items-center justify-between sm:justify-between">
{onDeleteTask && currentTask ? ( {onDeleteTask && currentTask ? (
<Button <Button
@ -470,7 +510,7 @@ export const TaskDetailDialog = ({
size="sm" size="sm"
onClick={() => { onClick={() => {
onDeleteTask(currentTask.id); onDeleteTask(currentTask.id);
onClose(); handleClose();
}} }}
> >
<Trash2 size={14} className="mr-1" /> <Trash2 size={14} className="mr-1" />
@ -479,7 +519,7 @@ export const TaskDetailDialog = ({
) : ( ) : (
<div /> <div />
)} )}
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={handleClose}>
Close Close
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -539,6 +539,7 @@ export const ChangeReviewDialog = ({
fileContentsLoading={fileContentsLoading} fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet} viewedSet={viewedSet}
editedContents={editedContents} editedContents={editedContents}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions} fileDecisions={fileDecisions}
collapseUnchanged={collapseUnchanged} collapseUnchanged={collapseUnchanged}
applying={applying} applying={applying}

View file

@ -91,4 +91,43 @@ export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) =
return { effects: originalDocChangeEffect(tr.startState, tr.changes) }; 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<string, string>
): 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 }; export { acceptChunk, getChunks, rejectChunk };

View file

@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils'; import { acceptAllChunks, rejectAllChunks, replayHunkDecisions } from './CodeMirrorDiffUtils';
import { FileSectionDiff } from './FileSectionDiff'; import { FileSectionDiff } from './FileSectionDiff';
import { FileSectionHeader } from './FileSectionHeader'; import { FileSectionHeader } from './FileSectionHeader';
import { FileSectionPlaceholder } from './FileSectionPlaceholder'; import { FileSectionPlaceholder } from './FileSectionPlaceholder';
@ -18,6 +18,7 @@ interface ContinuousScrollViewProps {
fileContentsLoading: Record<string, boolean>; fileContentsLoading: Record<string, boolean>;
viewedSet: Set<string>; viewedSet: Set<string>;
editedContents: Record<string, string>; editedContents: Record<string, string>;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>; fileDecisions: Record<string, HunkDecision>;
collapseUnchanged: boolean; collapseUnchanged: boolean;
applying: boolean; applying: boolean;
@ -48,6 +49,7 @@ export const ContinuousScrollView = ({
fileContentsLoading, fileContentsLoading,
viewedSet, viewedSet,
editedContents, editedContents,
hunkDecisions,
fileDecisions, fileDecisions,
collapseUnchanged, collapseUnchanged,
applying, applying,
@ -114,10 +116,12 @@ export const ContinuousScrollView = ({
[registerFileSectionRef, registerLazyRef] [registerFileSectionRef, registerLazyRef]
); );
// Ref to avoid stale closure — fileDecisions changes frequently // Refs to avoid stale closures — decisions change frequently
const fileDecisionsRef = useRef(fileDecisions); const fileDecisionsRef = useRef(fileDecisions);
const hunkDecisionsRef = useRef(hunkDecisions);
useEffect(() => { useEffect(() => {
fileDecisionsRef.current = fileDecisions; fileDecisionsRef.current = fileDecisions;
hunkDecisionsRef.current = hunkDecisions;
}); });
const handleEditorViewReady = useCallback( const handleEditorViewReady = useCallback(
@ -125,18 +129,21 @@ export const ContinuousScrollView = ({
if (view) { if (view) {
editorViewMapRef.current.set(filePath, view); editorViewMapRef.current.set(filePath, view);
// Sync pre-existing "Accept All" / "Reject All" decisions to newly mounted editors. const fileDecision = fileDecisionsRef.current[filePath];
// When Accept All runs, store is updated for ALL files, but CM only updates mounted ones. if (fileDecision === 'accepted' || fileDecision === 'rejected') {
// Lazily-loaded files mount later and need their CM state synced with the store. // Sync file-level "Accept All" / "Reject All" decisions
const decision = fileDecisionsRef.current[filePath];
if (decision === 'accepted' || decision === 'rejected') {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (decision === 'accepted') { if (fileDecision === 'accepted') {
acceptAllChunks(view); acceptAllChunks(view);
} else { } else {
rejectAllChunks(view); rejectAllChunks(view);
} }
}); });
} else {
// Replay individual per-hunk decisions persisted from previous session
requestAnimationFrame(() => {
replayHunkDecisions(view, filePath, hunkDecisionsRef.current);
});
} }
} else { } else {
editorViewMapRef.current.delete(filePath); editorViewMapRef.current.delete(filePath);

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
@ -7,6 +8,7 @@ import {
ChevronRight, ChevronRight,
Circle, Circle,
CircleDot, CircleDot,
Eye,
File, File,
Folder, Folder,
FolderOpen, FolderOpen,
@ -101,18 +103,36 @@ function getFileStatus(
return 'mixed'; return 'mixed';
} }
const statusLabels: Record<FileStatus, string> = {
accepted: 'All changes accepted',
rejected: 'All changes rejected',
mixed: 'Partially reviewed',
pending: 'Pending review',
};
const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => { const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => {
switch (status) { const icon = (() => {
case 'accepted': switch (status) {
return <Check className="size-3 shrink-0 text-green-400" />; case 'accepted':
case 'rejected': return <Check className="size-3 shrink-0 text-green-400" />;
return <XIcon className="size-3 shrink-0 text-red-400" />; case 'rejected':
case 'mixed': return <XIcon className="size-3 shrink-0 text-red-400" />;
return <CircleDot className="size-3 shrink-0 text-yellow-400" />; case 'mixed':
case 'pending': return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
default: case 'pending':
return <Circle className="size-3 shrink-0 text-zinc-500" />; default:
} return <Circle className="size-3 shrink-0 text-zinc-500" />;
}
})();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">{icon}</span>
</TooltipTrigger>
<TooltipContent side="top">{statusLabels[status]}</TooltipContent>
</Tooltip>
);
}; };
const TreeItem = ({ const TreeItem = ({
@ -123,8 +143,6 @@ const TreeItem = ({
depth, depth,
hunkDecisions, hunkDecisions,
viewedSet, viewedSet,
onMarkViewed,
onUnmarkViewed,
collapsedFolders, collapsedFolders,
onToggleFolder, onToggleFolder,
}: { }: {
@ -135,8 +153,6 @@ const TreeItem = ({
depth: number; depth: number;
hunkDecisions: Record<string, HunkDecision>; hunkDecisions: Record<string, HunkDecision>;
viewedSet?: Set<string>; viewedSet?: Set<string>;
onMarkViewed?: (filePath: string) => void;
onUnmarkViewed?: (filePath: string) => void;
collapsedFolders: Set<string>; collapsedFolders: Set<string>;
onToggleFolder: (fullPath: string) => void; onToggleFolder: (fullPath: string) => void;
}): JSX.Element => { }): JSX.Element => {
@ -160,22 +176,15 @@ const TreeItem = ({
> >
<FileStatusIcon status={status} /> <FileStatusIcon status={status} />
<File className="size-3.5 shrink-0" /> <File className="size-3.5 shrink-0" />
{viewedSet && ( {viewedSet && viewedSet.has(node.file.filePath) && (
<input <Tooltip>
type="checkbox" <TooltipTrigger asChild>
checked={viewedSet.has(node.file.filePath)} <span className="inline-flex shrink-0">
onChange={(e) => { <Eye className="size-3 shrink-0 text-blue-400" />
e.stopPropagation(); </span>
if (e.target.checked) { </TooltipTrigger>
onMarkViewed?.(node.file!.filePath); <TooltipContent side="top">Viewed</TooltipContent>
} else { </Tooltip>
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`}
/>
)} )}
<span <span
className={cn( className={cn(
@ -232,8 +241,6 @@ const TreeItem = ({
depth={depth + 1} depth={depth + 1}
hunkDecisions={hunkDecisions} hunkDecisions={hunkDecisions}
viewedSet={viewedSet} viewedSet={viewedSet}
onMarkViewed={onMarkViewed}
onUnmarkViewed={onUnmarkViewed}
collapsedFolders={collapsedFolders} collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder} onToggleFolder={onToggleFolder}
/> />
@ -277,8 +284,6 @@ export const ReviewFileTree = ({
selectedFilePath, selectedFilePath,
onSelectFile, onSelectFile,
viewedSet, viewedSet,
onMarkViewed,
onUnmarkViewed,
activeFilePath, activeFilePath,
}: ReviewFileTreeProps): JSX.Element => { }: ReviewFileTreeProps): JSX.Element => {
const hunkDecisions = useStore((state) => state.hunkDecisions); const hunkDecisions = useStore((state) => state.hunkDecisions);
@ -343,8 +348,6 @@ export const ReviewFileTree = ({
depth={0} depth={0}
hunkDecisions={hunkDecisions} hunkDecisions={hunkDecisions}
viewedSet={viewedSet} viewedSet={viewedSet}
onMarkViewed={onMarkViewed}
onUnmarkViewed={onUnmarkViewed}
collapsedFolders={collapsedFolders} collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder} onToggleFolder={toggleFolder}
/> />