mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
main
...
feat/comme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
978e70f5a3 | ||
|
|
93121ca585 | ||
|
|
6738b3f558 |
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, Copy, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { CheckCircle2, ChevronRight, ListChevronsDownUp, Copy, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -40,6 +40,8 @@ import type { TimelineEntry, Attachment } from "@multica/core/types";
|
||||
import { contentReferencesAttachment } from "@multica/core/types";
|
||||
import { useCommentCollapseStore, useCommentDraftStore } from "@multica/core/issues/stores";
|
||||
import { useT } from "../../i18n";
|
||||
import { CommentsFoldBar } from "./resolved-thread-bar";
|
||||
import { deriveThreadResolution } from "./thread-utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -69,7 +71,7 @@ interface CommentCardProps {
|
||||
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
/** Toggle the resolved state on the thread root. Only invoked for root entries. */
|
||||
/** Resolve/unresolve any comment in this thread (commentId = the target row). */
|
||||
onResolveToggle?: (commentId: string, resolved: boolean) => void;
|
||||
/**
|
||||
* When non-null, the thread root is currently rendered as a resolved-but-
|
||||
@@ -77,6 +79,13 @@ interface CommentCardProps {
|
||||
* can fold the thread back to the bar; the parent owns the session state.
|
||||
*/
|
||||
onCollapseResolved?: () => void;
|
||||
/**
|
||||
* Per-session set of thread ROOT ids whose reply-resolution fold is expanded.
|
||||
* Used only when a REPLY is the resolution (root-resolution folding is handled
|
||||
* one level up in issue-detail's resolved-bar). Keyed on root id.
|
||||
*/
|
||||
expandedResolvedIds?: ReadonlySet<string>;
|
||||
onResolvedExpandChange?: (rootId: string, expand: boolean) => void;
|
||||
/** ID of the comment to highlight (flash animation). */
|
||||
highlightedCommentId?: string | null;
|
||||
}
|
||||
@@ -322,19 +331,27 @@ function useEditAttachmentState(
|
||||
function CommentRow({
|
||||
issueId,
|
||||
entry,
|
||||
rootId,
|
||||
currentUserId,
|
||||
canModerate = false,
|
||||
isResolution = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleReaction,
|
||||
onResolveToggle,
|
||||
}: {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
/** Root comment id of this thread — target of "Resolve thread" from a reply. */
|
||||
rootId: string;
|
||||
currentUserId?: string;
|
||||
canModerate?: boolean;
|
||||
/** True when this reply is the thread's resolution (shows the green badge). */
|
||||
isResolution?: boolean;
|
||||
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
onResolveToggle?: (commentId: string, resolved: boolean) => void;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const timeAgo = useTimeAgo();
|
||||
@@ -353,7 +370,11 @@ function CommentRow({
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Header pins to the timeline's scroll parent within this reply's own
|
||||
row (the py-3 box is its containing block), so a LONG reply keeps its
|
||||
author + actions visible while you scroll its body, then releases once
|
||||
this reply ends. bg-card occludes the body scrolling underneath. */}
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2.5 bg-card">
|
||||
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
||||
<span className="cursor-pointer text-sm font-medium">
|
||||
{getActorName(entry.actor_type, entry.actor_id)}
|
||||
@@ -371,6 +392,12 @@ function CommentRow({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{isResolution && (
|
||||
<span className="text-xs font-medium text-success">
|
||||
{t(($) => $.comment.resolve.resolution_badge)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<QuickEmojiPicker
|
||||
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
@@ -393,6 +420,26 @@ function CommentRow({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.copy_action)}
|
||||
</DropdownMenuItem>
|
||||
{onResolveToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{isResolution ? (
|
||||
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, false)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.unresolve_action)}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, true)}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.resolve_with_comment_action)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onResolveToggle(rootId, true)}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.resolve_thread_action)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(canEditEntry || canDeleteEntry) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -507,6 +554,8 @@ function CommentCardImpl({
|
||||
onToggleReaction,
|
||||
onResolveToggle,
|
||||
onCollapseResolved,
|
||||
expandedResolvedIds,
|
||||
onResolvedExpandChange,
|
||||
highlightedCommentId,
|
||||
}: CommentCardProps) {
|
||||
const { t } = useT("issues");
|
||||
@@ -534,25 +583,60 @@ function CommentCardImpl({
|
||||
|
||||
const isHighlighted = highlightedCommentId === entry.id;
|
||||
|
||||
// Reply-resolution display. When a REPLY is the thread's resolution, the other
|
||||
// replies fold behind a bar and the resolution stays visible (root-resolution
|
||||
// is handled one level up in issue-detail's resolved-bar, so kind "root" here
|
||||
// renders the normal full thread under the Collapse header).
|
||||
const resolution = deriveThreadResolution(entry, allNestedReplies);
|
||||
const replyResolutionId = resolution.kind === "reply" ? resolution.resolutionId : null;
|
||||
const threadExpanded = !!expandedResolvedIds?.has(entry.id);
|
||||
const replyFolded = replyResolutionId != null && !threadExpanded;
|
||||
const foldedReplies = replyResolutionId
|
||||
? allNestedReplies.filter((r) => r.id !== replyResolutionId)
|
||||
: allNestedReplies;
|
||||
const resolutionReply = replyResolutionId
|
||||
? allNestedReplies.find((r) => r.id === replyResolutionId) ?? null
|
||||
: null;
|
||||
|
||||
// Pin the root comment's header to the timeline's scroll parent while the
|
||||
// thread is open, so a LONG root comment keeps its author + actions visible
|
||||
// as you scroll its body (overflow-clip on the Card anchors this to the
|
||||
// timeline, not the card — see below). The root-section wrapper below scopes
|
||||
// its containing block to the header + body, so it releases the moment the
|
||||
// replies begin — exactly one header is pinned at a time. Each reply pins its
|
||||
// header the same way, scoped to its own row (see CommentRow). Skip the root
|
||||
// header whenever a resolution collapse bar already owns the top-0 sticky slot
|
||||
// (root resolved + expanded, or reply-resolution expanded): two sticky bars at
|
||||
// the same offset would stack and hide one.
|
||||
const stickyHeader =
|
||||
open && !onCollapseResolved && !(replyResolutionId != null && threadExpanded);
|
||||
|
||||
return (
|
||||
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
|
||||
// overflow-clip (not -hidden) clips the rounded corners WITHOUT creating a
|
||||
// scroll container, so the sticky collapse affordances below resolve to the
|
||||
// timeline's scroll parent instead of this card. See PR #3623.
|
||||
<Card className={cn("!py-0 !gap-0 overflow-clip transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
|
||||
{onCollapseResolved && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapseResolved}
|
||||
className="flex w-full items-center justify-between border-b border-border/50 px-4 py-2.5 text-left text-sm text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
className="sticky top-0 z-20 flex w-full items-center gap-2.5 border-b border-border/50 bg-muted px-4 py-2.5 text-left text-sm text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label={t(($) => $.comment.resolve.collapse)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.collapse)}
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 -rotate-90" />
|
||||
<ListChevronsDownUp className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.collapse)}
|
||||
</button>
|
||||
)}
|
||||
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
||||
{/* root-section — the sticky header's containing block. It wraps ONLY
|
||||
the header + root body, so the header releases the moment you scroll
|
||||
past the body into the replies (which render OUTSIDE this wrapper).
|
||||
That is what keeps exactly one header pinned at a time: without this
|
||||
wrapper the header's containing block is the whole thread and it
|
||||
stays stuck behind every reply. */}
|
||||
<div>
|
||||
{/* Header — always visible, acts as toggle */}
|
||||
<div className="px-4 py-3">
|
||||
<div className={cn("px-4 py-3", stickyHeader && "sticky top-0 z-10 bg-card")}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
||||
@@ -615,12 +699,12 @@ function CommentCardImpl({
|
||||
{entry.resolved_at ? (
|
||||
<>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.unresolve_action)}
|
||||
{t(($) => $.comment.resolve.unresolve_thread_action)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.resolve_action)}
|
||||
{t(($) => $.comment.resolve.resolve_thread_action)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
@@ -728,35 +812,90 @@ function CommentCardImpl({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reply input */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
issueId={issueId}
|
||||
placeholder={t(($) => $.reply.placeholder)}
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
draftKey={`reply:${issueId}:${entry.id}`}
|
||||
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
|
||||
{/* Replies + reply input — rendered OUTSIDE root-section so the root
|
||||
header's sticky containing block ends with the body. Gated on `open`
|
||||
to mirror the body Panel's collapse visibility. */}
|
||||
{open && (
|
||||
<>
|
||||
{replyFolded ? (
|
||||
<>
|
||||
{/* reply-mode folded: other replies behind a bar, resolution pinned below */}
|
||||
{foldedReplies.length > 0 && (
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<CommentsFoldBar
|
||||
replies={foldedReplies}
|
||||
onExpand={() => onResolvedExpandChange?.(entry.id, true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{resolutionReply && (
|
||||
<div id={`comment-${resolutionReply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === resolutionReply.id && "bg-brand/5")}>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={resolutionReply}
|
||||
rootId={entry.id}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
isResolution
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onResolveToggle={onResolveToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* reply-mode expanded: a Collapse affordance to fold back */}
|
||||
{replyResolutionId != null && onResolvedExpandChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolvedExpandChange(entry.id, false)}
|
||||
className="sticky top-0 z-20 flex w-full items-center gap-2.5 border-t border-border/50 bg-muted px-4 py-2.5 text-left text-sm text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label={t(($) => $.comment.resolve.collapse)}
|
||||
>
|
||||
<ListChevronsDownUp className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.collapse)}
|
||||
</button>
|
||||
)}
|
||||
{/* Replies — chronological; the resolution keeps its place with a badge */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
rootId={entry.id}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
isResolution={reply.id === replyResolutionId}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onResolveToggle={onResolveToggle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reply input */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
issueId={issueId}
|
||||
placeholder={t(($) => $.reply.placeholder)}
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
draftKey={`reply:${issueId}:${entry.id}`}
|
||||
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ import { LocalDirectoryHint } from "../../projects/components/local-directory-hi
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { ResolvedThreadBar } from "./resolved-thread-bar";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import { collectThreadReplies, deriveThreadResolution } from "./thread-utils";
|
||||
import { IssueAgentHeaderChip } from "./issue-agent-header-chip";
|
||||
import { ExecutionLogSection } from "./execution-log-section";
|
||||
import { PullRequestList } from "./pull-request-list";
|
||||
@@ -732,6 +732,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
else next.delete(commentId);
|
||||
return next;
|
||||
});
|
||||
// On collapse the thread shrinks and the viewport would jump to whatever was
|
||||
// below; pull the just-folded thread back into view with the smallest
|
||||
// movement. rAF waits for the collapse to land before measuring.
|
||||
if (!expand) {
|
||||
requestAnimationFrame(() =>
|
||||
document.getElementById(`comment-${commentId}`)?.scrollIntoView({ block: "nearest" }),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
const clearResolvedExpand = useCallback((commentId: string) => {
|
||||
setExpandedResolved((prev) => {
|
||||
@@ -859,10 +867,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
// visually expanded after the second resolve.
|
||||
const handleResolveToggle = useCallback(
|
||||
(commentId: string, resolved: boolean) => {
|
||||
clearResolvedExpand(commentId);
|
||||
// Fold the thread back on any resolve change: clear the thread ROOT's
|
||||
// expand entry (expand state is keyed on root id, but a resolve target
|
||||
// can be a reply). Walk parent_id up to the root.
|
||||
const byId = new Map(timeline.map((e) => [e.id, e]));
|
||||
let cur = byId.get(commentId);
|
||||
while (cur?.parent_id && byId.get(cur.parent_id)) cur = byId.get(cur.parent_id)!;
|
||||
clearResolvedExpand(cur?.id ?? commentId);
|
||||
toggleResolveComment(commentId, resolved);
|
||||
},
|
||||
[clearResolvedExpand, toggleResolveComment],
|
||||
[timeline, clearResolvedExpand, toggleResolveComment],
|
||||
);
|
||||
|
||||
// Memoized timeline grouping. Each render rebuilds the per-parent map from
|
||||
@@ -1086,13 +1100,25 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
if (didHighlightRef.current === highlightCommentId) return;
|
||||
|
||||
const rootId = replyToRoot.get(highlightCommentId);
|
||||
if (
|
||||
rootId &&
|
||||
rootId !== highlightCommentId &&
|
||||
items[targetIdx]?.kind === "resolved-bar"
|
||||
) {
|
||||
toggleResolvedExpand(rootId, true);
|
||||
return;
|
||||
if (rootId && rootId !== highlightCommentId) {
|
||||
// Root resolved → the whole thread is a folded bar.
|
||||
if (items[targetIdx]?.kind === "resolved-bar") {
|
||||
toggleResolvedExpand(rootId, true);
|
||||
return;
|
||||
}
|
||||
// A reply is the resolution → the other replies fold behind the
|
||||
// "N comments" bar; expand if the target is one of those folded replies.
|
||||
const rootItem = items[targetIdx];
|
||||
if (rootItem?.kind === "comment" && !expandedResolved.has(rootId)) {
|
||||
const resolution = deriveThreadResolution(
|
||||
rootItem.entry,
|
||||
timelineView.threadReplies.get(rootId) ?? EMPTY_REPLIES,
|
||||
);
|
||||
if (resolution.kind === "reply" && resolution.resolutionId !== highlightCommentId) {
|
||||
toggleResolvedExpand(rootId, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const el = document.getElementById(`comment-${highlightCommentId}`);
|
||||
@@ -1138,7 +1164,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
cancelAnimationFrame(rafId);
|
||||
clearTimeout(fade);
|
||||
};
|
||||
}, [highlightCommentId, items, targetIdx, scrollContainerEl, replyToRoot, toggleResolvedExpand]);
|
||||
}, [highlightCommentId, items, targetIdx, scrollContainerEl, replyToRoot, expandedResolved, timelineView, toggleResolvedExpand]);
|
||||
|
||||
// Cmd-F / Ctrl-F on a virtualized timeline only searches what's mounted in
|
||||
// the viewport — off-screen comments are invisible to browser find-in-page.
|
||||
@@ -1616,6 +1642,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
onToggleReaction={handleToggleReaction}
|
||||
onResolveToggle={handleResolveToggle}
|
||||
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
|
||||
expandedResolvedIds={expandedResolved}
|
||||
onResolvedExpandChange={toggleResolvedExpand}
|
||||
highlightedCommentId={highlightedId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,37 +19,48 @@ interface ResolvedThreadBarProps {
|
||||
|
||||
const MAX_NAMED_AUTHORS = 2;
|
||||
|
||||
export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBarProps) {
|
||||
// Distinct authors across `entries`, first-seen order, collapsed to a label
|
||||
// ("Alice", "Alice, Bob", "Alice, Bob and 2 others"). Shared by both bars.
|
||||
function useAuthorsLabel(entries: TimelineEntry[]): string {
|
||||
const { t } = useT("issues");
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const authorKeys = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const authors: Array<{ type: string; id: string }> = [];
|
||||
for (const e of [entry, ...replies]) {
|
||||
for (const e of entries) {
|
||||
const key = `${e.actor_type}:${e.actor_id}`;
|
||||
if (authorKeys.has(key)) continue;
|
||||
authorKeys.add(key);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
authors.push({ type: e.actor_type, id: e.actor_id });
|
||||
}
|
||||
const count = 1 + replies.length;
|
||||
|
||||
let authorsLabel: string;
|
||||
if (authors.length <= MAX_NAMED_AUTHORS) {
|
||||
authorsLabel = authors.map((a) => getActorName(a.type, a.id)).join(", ");
|
||||
} else {
|
||||
const named = authors.slice(0, MAX_NAMED_AUTHORS).map((a) => getActorName(a.type, a.id)).join(", ");
|
||||
const remaining = authors.length - MAX_NAMED_AUTHORS;
|
||||
authorsLabel = t(($) => $.comment.resolve.bar_authors_more, { names: named, count: remaining });
|
||||
return authors.map((a) => getActorName(a.type, a.id)).join(", ");
|
||||
}
|
||||
const named = authors.slice(0, MAX_NAMED_AUTHORS).map((a) => getActorName(a.type, a.id)).join(", ");
|
||||
return t(($) => $.comment.resolve.bar_authors_more, {
|
||||
names: named,
|
||||
count: authors.length - MAX_NAMED_AUTHORS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-thread fold — the ROOT comment is resolved ("Resolve thread"). The
|
||||
* entire thread (root + every reply) collapses into this one bar.
|
||||
*/
|
||||
export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBarProps) {
|
||||
const { t } = useT("issues");
|
||||
const authorsLabel = useAuthorsLabel([entry, ...replies]);
|
||||
const count = 1 + replies.length;
|
||||
|
||||
return (
|
||||
<Card className="!py-0 !gap-0 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(($) => $.comment.resolve.bar, { count, authors: authorsLabel })}
|
||||
@@ -60,3 +71,34 @@ export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBa
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentsFoldBarProps {
|
||||
/** The non-resolution replies folded behind this bar. */
|
||||
replies: TimelineEntry[];
|
||||
onExpand: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle fold — a REPLY is the resolution ("Resolve thread with comment"). The
|
||||
* root and the resolution stay visible; the other replies fold behind this bar,
|
||||
* which sits between them.
|
||||
*/
|
||||
export function CommentsFoldBar({ replies, onExpand }: CommentsFoldBarProps) {
|
||||
const { t } = useT("issues");
|
||||
const authorsLabel = useAuthorsLabel(replies);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex w-full items-center justify-between rounded-md bg-muted/45 px-3 py-2.5 text-left transition-colors cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<ChevronRight className="h-3.5 w-3.5 rotate-90 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(($) => $.comment.resolve.fold, { count: replies.length, authors: authorsLabel })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,3 +22,33 @@ export function collectThreadReplies(
|
||||
walk(rootId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's resolution, derived purely from `resolved_at`. Two user actions
|
||||
* write the same field:
|
||||
* - "Resolve thread" sets resolved_at on the ROOT → whole thread folds.
|
||||
* - "Resolve thread with comment" sets resolved_at on a REPLY → that reply is
|
||||
* the resolution; the others fold around it.
|
||||
*
|
||||
* The derivation is total so the UI never shows two resolutions and never
|
||||
* crashes on any combination (older / concurrent writes can resolve more than
|
||||
* one): root wins; otherwise the reply with the latest resolved_at is THE
|
||||
* resolution. No write-side "clear the others" is needed — display picks one.
|
||||
*/
|
||||
export type ThreadResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "root" }
|
||||
| { kind: "reply"; resolutionId: string };
|
||||
|
||||
export function deriveThreadResolution(
|
||||
root: TimelineEntry,
|
||||
replies: TimelineEntry[],
|
||||
): ThreadResolution {
|
||||
if (root.resolved_at) return { kind: "root" };
|
||||
let chosen: TimelineEntry | null = null;
|
||||
for (const reply of replies) {
|
||||
if (!reply.resolved_at) continue;
|
||||
if (!chosen || reply.resolved_at > chosen.resolved_at!) chosen = reply;
|
||||
}
|
||||
return chosen ? { kind: "reply", resolutionId: chosen.id } : { kind: "none" };
|
||||
}
|
||||
|
||||
@@ -276,11 +276,16 @@
|
||||
"leave_comment_placeholder": "Leave a comment...",
|
||||
"send_tooltip": "Send",
|
||||
"resolve": {
|
||||
"resolve_action": "Resolve thread",
|
||||
"unresolve_action": "Unresolve thread",
|
||||
"resolve_thread_action": "Resolve thread",
|
||||
"unresolve_thread_action": "Unresolve thread",
|
||||
"resolve_with_comment_action": "Resolve thread with comment",
|
||||
"unresolve_action": "Unresolve",
|
||||
"resolution_badge": "Resolution",
|
||||
"collapse": "Collapse",
|
||||
"bar_one": "{{count}} resolved comment from {{authors}}",
|
||||
"bar_other": "{{count}} resolved comments from {{authors}}",
|
||||
"fold_one": "{{count}} comment from {{authors}}",
|
||||
"fold_other": "{{count}} comments from {{authors}}",
|
||||
"bar_authors_more_one": "{{names}} and {{count}} other",
|
||||
"bar_authors_more_other": "{{names}} and {{count}} others",
|
||||
"resolve_failed": "Failed to resolve thread",
|
||||
|
||||
@@ -268,10 +268,14 @@
|
||||
"leave_comment_placeholder": "コメントを残す...",
|
||||
"send_tooltip": "送信",
|
||||
"resolve": {
|
||||
"resolve_action": "スレッドを解決",
|
||||
"unresolve_action": "スレッドの解決を取り消す",
|
||||
"resolve_thread_action": "スレッドを解決",
|
||||
"unresolve_thread_action": "スレッドの解決を取り消す",
|
||||
"resolve_with_comment_action": "このコメントでスレッドを解決",
|
||||
"unresolve_action": "解決を取り消す",
|
||||
"resolution_badge": "解決",
|
||||
"collapse": "折りたたむ",
|
||||
"bar_other": "{{authors}} による解決済みコメント {{count}} 件",
|
||||
"fold_other": "{{authors}} のコメント {{count}} 件",
|
||||
"bar_authors_more_other": "{{names}} 他 {{count}} 名",
|
||||
"resolve_failed": "スレッドを解決できませんでした",
|
||||
"unresolve_failed": "スレッドの解決を取り消せませんでした"
|
||||
|
||||
@@ -276,11 +276,16 @@
|
||||
"leave_comment_placeholder": "댓글 남기기...",
|
||||
"send_tooltip": "보내기",
|
||||
"resolve": {
|
||||
"resolve_action": "스레드 해결",
|
||||
"unresolve_action": "스레드 해결 취소",
|
||||
"resolve_thread_action": "스레드 해결",
|
||||
"unresolve_thread_action": "스레드 해결 취소",
|
||||
"resolve_with_comment_action": "이 댓글로 스레드 해결",
|
||||
"unresolve_action": "해결 취소",
|
||||
"resolution_badge": "해결",
|
||||
"collapse": "접기",
|
||||
"bar_one": "{{authors}}님의 해결된 댓글 {{count}}개",
|
||||
"bar_other": "{{authors}}님의 해결된 댓글 {{count}}개",
|
||||
"fold_one": "{{authors}}님의 댓글 {{count}}개",
|
||||
"fold_other": "{{authors}}님의 댓글 {{count}}개",
|
||||
"bar_authors_more_one": "{{names}} 외 {{count}}명",
|
||||
"bar_authors_more_other": "{{names}} 외 {{count}}명",
|
||||
"resolve_failed": "스레드를 해결하지 못했습니다",
|
||||
|
||||
@@ -273,10 +273,14 @@
|
||||
"leave_comment_placeholder": "留下评论...",
|
||||
"send_tooltip": "发送",
|
||||
"resolve": {
|
||||
"resolve_action": "标记为已解决",
|
||||
"resolve_thread_action": "解决该讨论",
|
||||
"unresolve_thread_action": "重新打开讨论",
|
||||
"resolve_with_comment_action": "以此评论解决讨论",
|
||||
"unresolve_action": "重新打开",
|
||||
"resolution_badge": "结论",
|
||||
"collapse": "收起",
|
||||
"bar_other": "来自 {{authors}} 的 {{count}} 条已解决评论",
|
||||
"fold_other": "来自 {{authors}} 的 {{count}} 条评论",
|
||||
"bar_authors_more_other": "{{names}} 等 {{count}} 人",
|
||||
"resolve_failed": "标记已解决失败",
|
||||
"unresolve_failed": "重新打开失败"
|
||||
|
||||
@@ -1395,11 +1395,14 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// loadRootCommentForActor resolves a {commentId} URL param to a root comment in
|
||||
// the caller's workspace. Returns the comment, the workspace UUID, the actor
|
||||
// loadCommentForActor resolves a {commentId} URL param to a comment in the
|
||||
// caller's workspace. Returns the comment, the workspace UUID, the actor
|
||||
// identity, and ok. Resolve / unresolve handlers share this scaffolding so the
|
||||
// "must be a root comment" rule lives in one place.
|
||||
func (h *Handler) loadRootCommentForActor(w http.ResponseWriter, r *http.Request) (db.Comment, string, string, string, bool) {
|
||||
// workspace membership + tenant guard stay identical. Any comment (root or
|
||||
// reply) may be resolved: resolving a root collapses the whole thread; resolving
|
||||
// a reply marks it as the thread's resolution. Which one is the thread's
|
||||
// resolution is a pure frontend derivation, so the backend stays a plain setter.
|
||||
func (h *Handler) loadCommentForActor(w http.ResponseWriter, r *http.Request) (db.Comment, string, string, string, bool) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
@@ -1425,16 +1428,12 @@ func (h *Handler) loadRootCommentForActor(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return db.Comment{}, "", "", "", false
|
||||
}
|
||||
if comment.ParentID.Valid {
|
||||
writeError(w, http.StatusBadRequest, "only root comments can be resolved")
|
||||
return db.Comment{}, "", "", "", false
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
return comment, workspaceID, actorType, actorID, true
|
||||
}
|
||||
|
||||
func (h *Handler) ResolveComment(w http.ResponseWriter, r *http.Request) {
|
||||
comment, workspaceID, actorType, actorID, ok := h.loadRootCommentForActor(w, r)
|
||||
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -1471,7 +1470,7 @@ func (h *Handler) ResolveComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) UnresolveComment(w http.ResponseWriter, r *http.Request) {
|
||||
comment, workspaceID, actorType, actorID, ok := h.loadRootCommentForActor(w, r)
|
||||
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user