Files
multica/packages/views/issues/components/resolved-thread-bar.tsx
Jiayuan Zhang 3b3be9d7bd feat(comments): resolve threads with collapsible bar (MUL-1895) (#2300)
* feat(comments): resolve threads with collapsible bar (MUL-1895)

Adds a Linear-style resolve action on comment thread roots. Resolved
threads collapse to a single "N resolved comments from X" bar in the
activity feed; clicking expands the thread inline (per-session, not
persisted). Replying inside a resolved thread auto-unresolves it.

Backend
- migration 069: resolved_at, resolved_by_type, resolved_by_id on comment
- sqlc ResolveComment / UnresolveComment queries (idempotent via COALESCE)
- POST/DELETE /api/comments/{id}/resolve handlers, root-only validation
- CreateComment auto-clears resolved_at when a reply lands in a resolved
  thread, publishing comment:unresolved
- comment:resolved / comment:unresolved events; CommentResponse and
  TimelineEntry both surface the new fields

Frontend
- Comment + TimelineEntry types extended; payloads typed; WS sync wired
- useResolveComment optimistic mutation with rollback
- ResolvedThreadBar component for the collapsed view
- Resolve / Unresolve menu items on root comments; Collapse strip on the
  expanded resolved card
- en + zh-Hans locale strings

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): cover agent reply path, expand-state hygiene, nested counts (MUL-1895)

Addresses three review issues from Emacs on PR #2300:

1. TaskService.createAgentComment bypasses Handler.CreateComment, so the
   auto-unresolve wired into the handler did not fire when an agent replied
   in a resolved thread (task / mention / on_comment paths). Extracted the
   logic to TaskService.AutoUnresolveThreadOnReply so both reply paths share
   it; rewired Handler.CreateComment to call the new method.

2. Resolving an already-expanded thread no longer collapses it back to the
   bar because expandedResolved still contained the id. Added
   clearResolvedExpand + handleResolveToggle wrapper so resolve / unresolve
   always wipe the session expand entry.

3. ResolvedThreadBar received only direct children, while CommentCard's
   expanded view recurses through descendants. Extracted the recursive
   walk into thread-utils.collectThreadReplies and called from both —
   counts and author lists now match.

Co-authored-by: multica-agent <github@multica.ai>

* test(comments): mock useResolveComment + add zh-Hans plural key

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 05:49:33 +02:00

65 lines
2.4 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 { collectThreadReplies } from "./thread-utils";
import { useT } from "../../i18n";
interface ResolvedThreadBarProps {
/** The resolved root comment. */
entry: TimelineEntry;
/**
* Full reply graph keyed by parent_id. The bar walks the graph recursively
* so the count + author list match what CommentCard would render in the
* expanded view (direct-children-only would undercount nested replies).
*/
repliesByParent: Map<string, TimelineEntry[]>;
onExpand: () => void;
}
const MAX_NAMED_AUTHORS = 2;
export function ResolvedThreadBar({ entry, repliesByParent, onExpand }: ResolvedThreadBarProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const replies = collectThreadReplies(entry.id, repliesByParent);
const authorKeys = new Set<string>();
const authors: Array<{ type: string; id: string }> = [];
for (const e of [entry, ...replies]) {
const key = `${e.actor_type}:${e.actor_id}`;
if (authorKeys.has(key)) continue;
authorKeys.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 (
<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"
>
<span className="flex 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>
);
}