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

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[];
/** 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>
);
};

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

View file

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

View file

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

View file

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

View file

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