mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* 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>
65 lines
2.4 KiB
TypeScript
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>
|
|
);
|
|
}
|