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>
55 lines
2.0 KiB
TypeScript
55 lines
2.0 KiB
TypeScript
import type { TimelineEntry } from "@multica/core/types";
|
|
|
|
/**
|
|
* Walks the parent_id graph rooted at `rootId` and returns every descendant in
|
|
* traversal order. Shared between CommentCard (which renders the expanded
|
|
* thread) and ResolvedThreadBar (which displays the collapsed count + author
|
|
* list) so the two views stay in sync — direct-children-only counts diverge
|
|
* once nested replies exist (see Emacs review on PR #2300).
|
|
*/
|
|
export function collectThreadReplies(
|
|
rootId: string,
|
|
repliesByParent: Map<string, TimelineEntry[]>,
|
|
): TimelineEntry[] {
|
|
const out: TimelineEntry[] = [];
|
|
const walk = (id: string) => {
|
|
const children = repliesByParent.get(id) ?? [];
|
|
for (const child of children) {
|
|
out.push(child);
|
|
walk(child.id);
|
|
}
|
|
};
|
|
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" };
|
|
}
|