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 {
|
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
|
||||||
|
|
|
||||||
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[];
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue