mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
* 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>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
import { CheckCircle2, ChevronRight } from "lucide-react";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { Card } from "@multica/ui/components/ui/card";
|
|
import type { TimelineEntry } from "@multica/core/types";
|
|
import { useT } from "../../i18n";
|
|
|
|
interface ResolvedThreadBarProps {
|
|
/** The resolved root comment. */
|
|
entry: TimelineEntry;
|
|
/**
|
|
* Flat list of every nested reply under this thread root. Precomputed by
|
|
* `issue-detail.tsx`'s `timelineView` from the same walk that CommentCard
|
|
* uses, so the count + author list match what the expanded view renders
|
|
* (direct-children-only would undercount nested replies).
|
|
*/
|
|
replies: TimelineEntry[];
|
|
onExpand: () => void;
|
|
}
|
|
|
|
const MAX_NAMED_AUTHORS = 2;
|
|
|
|
// 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 seen = new Set<string>();
|
|
const authors: Array<{ type: string; id: string }> = [];
|
|
for (const e of entries) {
|
|
const key = `${e.actor_type}:${e.actor_id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
authors.push({ type: e.actor_type, id: e.actor_id });
|
|
}
|
|
|
|
if (authors.length <= MAX_NAMED_AUTHORS) {
|
|
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 transition-colors cursor-pointer hover:bg-muted/50"
|
|
>
|
|
<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 })}
|
|
</span>
|
|
</span>
|
|
<ChevronRight className="h-3.5 w-3.5 rotate-90 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|