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:
parent
7019bf6114
commit
0d0786602f
8 changed files with 383 additions and 132 deletions
|
|
@ -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 ? <span className="shrink-0 text-[var(--color-text-muted)]">{icon}</span> : null}
|
||||
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
|
||||
{badge != null && (
|
||||
<Badge
|
||||
|
|
|
|||
145
src/renderer/components/team/dialogs/TaskCommentInput.tsx
Normal file
145
src/renderer/components/team/dialogs/TaskCommentInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 = ({
|
|||
<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"
|
||||
onClick={() =>
|
||||
setReplyTo({
|
||||
author: comment.author,
|
||||
text: stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
),
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
const replyText = stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
);
|
||||
if (onReply) {
|
||||
onReply(comment.author, replyText);
|
||||
} else {
|
||||
setReplyTo({ author: comment.author, text: replyText });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
|
|
@ -222,80 +230,84 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
{!hideInput && (
|
||||
<>
|
||||
{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={() => setReplyTo(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Cancel reply</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
) : null}
|
||||
) : 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)]'}`}
|
||||
<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()}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Task not found</DialogTitle>
|
||||
|
|
@ -254,7 +281,7 @@ export const TaskDetailDialog = ({
|
|||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<CollapsibleTeamSection title="Description" defaultOpen>
|
||||
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
|
||||
{currentTask.description ? (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
|
||||
|
|
@ -265,7 +292,7 @@ export const TaskDetailDialog = ({
|
|||
</CollapsibleTeamSection>
|
||||
|
||||
{/* 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">
|
||||
<MemberLogsTab
|
||||
teamName={teamName}
|
||||
|
|
@ -280,6 +307,7 @@ export const TaskDetailDialog = ({
|
|||
{isTaskCompleted && onViewChanges ? (
|
||||
<CollapsibleTeamSection
|
||||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
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 */}
|
||||
<CollapsibleTeamSection
|
||||
title="Comments"
|
||||
icon={<MessageSquare size={14} />}
|
||||
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}
|
||||
/>
|
||||
</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">
|
||||
{onDeleteTask && currentTask ? (
|
||||
<Button
|
||||
|
|
@ -470,7 +510,7 @@ export const TaskDetailDialog = ({
|
|||
size="sm"
|
||||
onClick={() => {
|
||||
onDeleteTask(currentTask.id);
|
||||
onClose();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
|
|
@ -479,7 +519,7 @@ export const TaskDetailDialog = ({
|
|||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -539,6 +539,7 @@ export const ChangeReviewDialog = ({
|
|||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
|
|
|
|||
|
|
@ -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<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 };
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>;
|
||||
viewedSet: Set<string>;
|
||||
editedContents: Record<string, string>;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<FileStatus, string> = {
|
||||
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 <Check className="size-3 shrink-0 text-green-400" />;
|
||||
case 'rejected':
|
||||
return <XIcon className="size-3 shrink-0 text-red-400" />;
|
||||
case 'mixed':
|
||||
return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Circle className="size-3 shrink-0 text-zinc-500" />;
|
||||
}
|
||||
const icon = (() => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return <Check className="size-3 shrink-0 text-green-400" />;
|
||||
case 'rejected':
|
||||
return <XIcon className="size-3 shrink-0 text-red-400" />;
|
||||
case 'mixed':
|
||||
return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
|
||||
case 'pending':
|
||||
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 = ({
|
||||
|
|
@ -123,8 +143,6 @@ const TreeItem = ({
|
|||
depth,
|
||||
hunkDecisions,
|
||||
viewedSet,
|
||||
onMarkViewed,
|
||||
onUnmarkViewed,
|
||||
collapsedFolders,
|
||||
onToggleFolder,
|
||||
}: {
|
||||
|
|
@ -135,8 +153,6 @@ const TreeItem = ({
|
|||
depth: number;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
viewedSet?: Set<string>;
|
||||
onMarkViewed?: (filePath: string) => void;
|
||||
onUnmarkViewed?: (filePath: string) => void;
|
||||
collapsedFolders: Set<string>;
|
||||
onToggleFolder: (fullPath: string) => void;
|
||||
}): JSX.Element => {
|
||||
|
|
@ -160,22 +176,15 @@ const TreeItem = ({
|
|||
>
|
||||
<FileStatusIcon status={status} />
|
||||
<File className="size-3.5 shrink-0" />
|
||||
{viewedSet && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewedSet.has(node.file.filePath)}
|
||||
onChange={(e) => {
|
||||
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) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0">
|
||||
<Eye className="size-3 shrink-0 text-blue-400" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Viewed</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -232,8 +241,6 @@ const TreeItem = ({
|
|||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={onMarkViewed}
|
||||
onUnmarkViewed={onUnmarkViewed}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue