Compare commits

...

3 Commits

Author SHA1 Message Date
Naiyuan Qing
978e70f5a3 feat(issues): sticky comment headers for long comments
Pin each comment's header (root + replies) to the timeline's scroll
parent while reading, so a long comment keeps its author + actions
visible instead of scrolling out of reach. Exactly one header is pinned
at a time:

- Reply headers stick within their own CommentRow box (release at the
  reply's end).
- The root header is wrapped in a root-section container so its sticky
  containing block spans only the header + root body — without it the
  containing block is the whole thread and the root header stays stuck
  behind every reply. Replies render outside the wrapper, gated on open.
- Skip the root header sticky whenever a resolution collapse bar already
  owns the top-0 slot (root resolved+expanded, or reply-resolution
  expanded) to avoid two bars stacking at the same offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:36:33 +08:00
Naiyuan Qing
93121ca585 Merge remote-tracking branch 'origin/main' into feat/comment-resolution
# Conflicts:
#	packages/views/issues/components/issue-detail.tsx
2026-06-09 14:56:43 +08:00
Naiyuan Qing
6738b3f558 feat(issues): per-comment thread resolution with sticky collapse
Allow resolving any comment, not just roots. Resolving a root folds the
whole thread into one bar (existing); resolving a reply marks it as the
thread's resolution ("Resolve thread with comment") and folds the other
replies behind a "N comments" bar, with the resolution kept visible and
badged. Which comment is the resolution is a pure frontend derivation
(root wins, else latest resolved reply), so no write-side bookkeeping is
needed and any resolved_at combination renders one resolution.

- backend: drop the "only root comments can be resolved" guard
- views: deriveThreadResolution + reply-resolution rendering, sticky
  collapse/fold bars (overflow-clip on the card so sticky resolves to the
  timeline scroll parent), scroll the folded thread back into view on
  collapse, ListChevronsDownUp icon, locales (en/ja/ko/zh-Hans)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:29:18 +08:00
9 changed files with 339 additions and 83 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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": "スレッドの解決を取り消せませんでした"

View File

@@ -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": "스레드를 해결하지 못했습니다",

View File

@@ -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": "重新打开失败"

View File

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