mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* perf(issues): stop full timeline re-render on every WS event (MUL-1941) Two compounding causes made every Comment/reply WS event re-render every sibling thread on the issue detail page — visible during AI streaming as a flash across all 10 nested replies under a parent and as the green reply-input losing its draft. 1) `useCreateComment.onSettled` invalidated the timeline query, forcing a full `GET /timeline` refetch on every comment submit. The response replaced every entry's reference even when the content was unchanged, poisoning every downstream React.memo. The `comment:created` WS broadcast already keeps the cache fresh and `useWSReconnect` invalidates on disconnect, so the redundant refetch had no upside. Drop it. 2) The `timelineView` useMemo passed the full `repliesByParent: Map` to every CommentCard. Each WS event rebuilt the Map (new ref), so React.memo on CommentCard fell back to a re-render for *every* card, not just the one whose thread changed. Replace the Map prop with a per-thread `replies: TimelineEntry[]` slice, precomputed once via `collectThreadReplies` and stabilized against the prior render — when a thread's flat list is shallow-equal to last time, reuse the previous array reference so unrelated cards keep their memo. ResolvedThreadBar gets the same `replies` prop, so the collapsed count + author list still match the expanded view without re-walking the graph. Verified: pnpm typecheck + pnpm test for @multica/views and @multica/core (334 + 214 tests, all passing). Co-authored-by: multica-agent <github@multica.ai> * fix(realtime): mark timeline stale without refetching active queries (MUL-1941) Per GPT-Boy's review on PR #2329: dropping `useCreateComment.onSettled`'s invalidate wasn't enough. The global `useRealtimeSync` runs in WSProvider for the lifetime of the app and re-invalidates the timeline on every `comment:created` / `comment:updated` / `comment:deleted` / `comment:resolved` / `comment:unresolved` / `activity:created` / `reaction:added` / `reaction:removed` event. With `staleTime: Infinity` on the QueryClient default, the active timeline query refetches on every invalidate — replacing every entry's reference and busting the per-thread memoization the prior commit just put in place. Switch the global handler's `invalidateQueries` to `refetchType: "none"`. Active observers now stay fresh via the granular `setQueryData` handlers in `useIssueTimeline`; inactive issues' caches are still marked stale, so when IssueDetail mounts later, `refetchOnMount` triggers a fresh fetch the same way it did before. `comment:resolved` / `comment:unresolved` previously had no granular handler — only the global invalidate kept the cache in sync. Add useWSEvent handlers in `useIssueTimeline` that replace the matching entry via `commentToTimelineEntry`, and extend that helper to carry the resolved_at / resolved_by_type / resolved_by_id fields so resolved state survives the round-trip (it was silently dropped on every `comment:updated` too — fixed as a side effect). Tests: 3 new cases covering resolved / unresolved / cross-issue isolation in the timeline hook. All 337 + 214 unit tests + full monorepo typecheck pass. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
63 lines
2.3 KiB
TypeScript
63 lines
2.3 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;
|
|
|
|
export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBarProps) {
|
|
const { t } = useT("issues");
|
|
const { getActorName } = useActorName();
|
|
|
|
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>
|
|
);
|
|
}
|