perf: virtualize issue detail timeline + seed test scaffolding (#2413)

* perf(views): virtualize issue detail timeline with react-virtuoso

The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.

Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.

Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.

Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.

Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views): repair deep-link scroll and isolate comment drafts

The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:

1. Deep-link landing position was wrong by ~380px on every issue.
   In customScrollParent mode Virtuoso computes scrollTop from the
   list's internal coordinate space only — it doesn't account for
   sibling content (title editor, description, sub-issues, agent
   card) sitting above the list inside the same scroll parent. The
   useEffect now uses Virtuoso scrollToIndex only to MOUNT the
   target into the DOM, then polls a `data-comment-id` anchor and
   delegates positioning to the browser's scrollIntoView, which
   honors getBoundingClientRect and lands accurately every time.

2. Scroll-up was being yanked back to the deep-link anchor on every
   ResizeObserver tick. Root cause was `followOutput="auto"`, which
   stays "stuck to bottom" once the deep-link lands there and resets
   scrollTop to maxScrollTop on each height change. Issue detail is
   document-shaped, not chat-shaped, so removing followOutput
   altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
   acts as a persistent anchor in customScrollParent mode (Virtuoso
   #458) — dropped entirely and replaced with imperative scroll.
   `defaultItemHeight` is also dropped so Virtuoso probes real
   heights instead of estimating + correcting visually.

3. Reply-comment deep-links from the inbox would short-circuit
   because the reply id isn't in the flat items[] array. Added a
   replyToRoot map so deep-link falls back to the enclosing thread's
   root index, scrolls there, and lets the reply's own ring fire
   once the thread is in view.

Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.

Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-11 18:56:27 +08:00
committed by GitHub
parent b7cd7e9adf
commit fb8ad8cc5e
12 changed files with 616 additions and 141 deletions

View File

@@ -0,0 +1,80 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Per-comment draft persistence — survives:
* - virtualization unmount (the reason this exists: when a TipTap editor
* scrolls out of the Virtuoso viewport, its in-memory state is lost)
* - tab close / accidental Cmd-W
* - reload
*
* Keys are issue-scoped because createWorkspaceAwareStorage only partitions
* by workspace, not by issue. Without issueId in the key, two issues with
* thread replies open in adjacent desktop tabs would collide.
*/
export type CommentDraftKey =
| `new:${string}` // top-level CommentInput, key = `new:${issueId}`
| `reply:${string}:${string}` // ReplyInput inside a thread, key = `reply:${issueId}:${rootCommentId}`
| `edit:${string}:${string}`; // inline edit on existing comment, key = `edit:${issueId}:${commentId}`
interface CommentDraft {
content: string;
updatedAt: number;
}
interface CommentDraftStore {
drafts: Record<string, CommentDraft>;
getDraft: (key: CommentDraftKey) => string | undefined;
setDraft: (key: CommentDraftKey, content: string) => void;
clearDraft: (key: CommentDraftKey) => void;
}
// Drafts older than 30 days are dropped on store init. Without TTL the store
// would accumulate every edit attempt across every issue indefinitely and
// slowly leak localStorage quota.
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
function pruneStaleDrafts(drafts: Record<string, CommentDraft>): Record<string, CommentDraft> {
const cutoff = Date.now() - TTL_MS;
const out: Record<string, CommentDraft> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.updatedAt >= cutoff && v.content.trim().length > 0) {
out[k] = v;
}
}
return out;
}
export const useCommentDraftStore = create<CommentDraftStore>()(
persist(
(set, get) => ({
drafts: {},
getDraft: (key) => get().drafts[key]?.content,
setDraft: (key, content) =>
set((s) => ({
drafts: { ...s.drafts, [key]: { content, updatedAt: Date.now() } },
})),
clearDraft: (key) =>
set((s) => {
if (!(key in s.drafts)) return s;
const next = { ...s.drafts };
delete next[key];
return { drafts: next };
}),
}),
{
name: "multica_comment_drafts",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
onRehydrateStorage: () => (state) => {
if (state) {
state.drafts = pruneStaleDrafts(state.drafts);
}
},
},
),
);
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());

View File

@@ -13,6 +13,7 @@ export {
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -36,7 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore } from "@multica/core/issues/stores";
import { useCommentCollapseStore, useCommentDraftStore } from "@multica/core/issues/stores";
import { useT } from "../../i18n";
// ---------------------------------------------------------------------------
@@ -201,6 +201,22 @@ function CommentRow({
enabled: editing,
});
// Edit-mode draft: virtualization unmounts the card when it scrolls out
// of viewport, taking the in-progress edit with it. Persist via store
// so a scroll-away + scroll-back round-trip restores the user's edits.
// Key includes issueId so two issues with the same comment id (impossible
// but defensive) don't collide; cleared on cancel and on save.
const editDraftKey = `edit:${issueId}:${entry.id}` as const;
const getEditDraft = useCommentDraftStore.getState().getDraft;
const setEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearEditDraft = useCommentDraftStore((s) => s.clearDraft);
// Read the snapshot once when the edit pass mounts; ContentEditor only
// honors `defaultValue` on mount, so a live store subscription here would
// cause an extra unmount/remount on every keystroke.
const editInitialValue = editing
? (getEditDraft(editDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
@@ -215,6 +231,7 @@ function CommentRow({
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
clearEditDraft(editDraftKey);
};
const saveEdit = async () => {
@@ -225,11 +242,13 @@ function CommentRow({
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
clearEditDraft(editDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
clearEditDraft(editDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
}
@@ -319,8 +338,12 @@ function CommentRow({
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
defaultValue={editInitialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setEditDraft(editDraftKey, md);
else clearEditDraft(editDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
@@ -395,6 +418,15 @@ function CommentCardImpl({
enabled: editing,
});
// Edit-mode draft (root comment). Same rationale as CommentRow's draft.
const parentEditDraftKey = `edit:${issueId}:${entry.id}` as const;
const getParentEditDraft = useCommentDraftStore.getState().getDraft;
const setParentEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearParentEditDraft = useCommentDraftStore((s) => s.clearDraft);
const parentEditInitialValue = editing
? (getParentEditDraft(parentEditDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
// Author-only edit is the same as before; admins additionally get edit
// *and* delete on member-authored comments, plus delete on agent-authored
@@ -413,6 +445,7 @@ function CommentCardImpl({
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
};
const saveEdit = async () => {
@@ -423,11 +456,13 @@ function CommentCardImpl({
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
}
@@ -581,8 +616,12 @@ function CommentCardImpl({
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
defaultValue={parentEditInitialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setParentEditDraft(parentEditDraftKey, md);
else clearParentEditDraft(parentEditDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
@@ -624,7 +663,7 @@ function CommentCardImpl({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<div key={reply.id} data-comment-id={reply.id} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}
@@ -645,6 +684,7 @@ function CommentCardImpl({
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
draftKey={`reply:${issueId}:${entry.id}`}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { useRef, useState, useCallback, useEffect } from "react";
import { Maximize2, Minimize2 } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { cn } from "@multica/ui/lib/utils";
@@ -10,6 +10,7 @@ import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import { useCommentDraftStore } from "@multica/core/issues/stores";
import { useT } from "../../i18n";
interface CommentInputProps {
@@ -20,7 +21,13 @@ interface CommentInputProps {
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const { t } = useT("issues");
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
// Read the persisted draft once on mount. ContentEditor only honors
// `defaultValue` at mount time, so this snapshot drives both the editor's
// initial content and the submit-button enable state — without this the
// button would be disabled even though the editor visibly contains text.
const draftKey = `new:${issueId}` as const;
const initialDraft = useCommentDraftStore.getState().getDraft(draftKey);
const [isEmpty, setIsEmpty] = useState(() => !initialDraft?.trim());
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
@@ -29,6 +36,26 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
// Draft persistence. Hydrate from store on mount via `defaultValue` above
// (ContentEditorRef has no setContent, so this is the only injection point).
// Flush on every onUpdate (debounced upstream) + visibilitychange/pagehide
// so tab close / mobile background doesn't lose work. Cleared on submit.
const setDraft = useCommentDraftStore((s) => s.setDraft);
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
useEffect(() => {
const flush = () => {
const md = editorRef.current?.getMarkdown();
if (md && md.trim().length > 0) setDraft(draftKey, md);
};
const onVis = () => { if (document.visibilityState === "hidden") flush(); };
document.addEventListener("visibilitychange", onVis);
window.addEventListener("pagehide", flush);
return () => {
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("pagehide", flush);
};
}, [draftKey, setDraft]);
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
@@ -51,6 +78,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -67,8 +95,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}
placeholder={t(($) => $.comment.leave_comment_placeholder)}
onUpdate={(md) => setIsEmpty(!md.trim())}
onUpdate={(md) => {
setIsEmpty(!md.trim());
// Debounced upstream (debounceMs=100). Persist on every tick so a
// reload or scroll-out-of-viewport restores work to the keystroke.
if (md.trim().length > 0) setDraft(draftKey, md);
else clearDraft(draftKey);
}}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}

View File

@@ -264,6 +264,39 @@ vi.mock("@multica/core/issues/stores", () => ({
};
return selector ? selector(state) : state;
},
useCommentDraftStore: Object.assign(
(selector?: any) => {
const state = {
drafts: {} as Record<string, { content: string; updatedAt: number }>,
getDraft: () => undefined,
setDraft: () => {},
clearDraft: () => {},
};
return selector ? selector(state) : state;
},
{
getState: () => ({
drafts: {} as Record<string, { content: string; updatedAt: number }>,
getDraft: () => undefined,
setDraft: () => {},
clearDraft: () => {},
}),
},
),
}));
// Mock react-virtuoso: jsdom has no real layout, so the real Virtuoso would
// compute a 0-height viewport and render nothing. The mock renders every item
// inline, which matches how the unvirtualized .map used to behave and keeps
// existing assertions (`getByText('Started working on this')` etc.) working.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (i: number, item: unknown) => unknown }) => (
<div data-testid="virtuoso-mock">
{data.map((item, i) => (
<div key={i}>{itemContent(i, item) as React.ReactElement}</div>
))}
</div>
),
}));
// Mock modals
@@ -558,28 +591,30 @@ describe("IssueDetail (shared)", () => {
it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => {
renderIssueDetailWithHighlight("comment-2");
// Wait until the comment DOM is rendered.
// Under virtualization the DOM anchor is a `data-comment-id` attribute
// on the item wrapper; we no longer rely on element id="comment-...".
await waitFor(() => {
expect(document.getElementById("comment-comment-2")).not.toBeNull();
expect(
document.querySelector('[data-comment-id="comment-2"]'),
).not.toBeNull();
});
// requestAnimationFrame defers the actual scrollIntoView call.
// The deep-link useEffect polls until the target mounts, then calls
// scrollIntoView on the wrapper element.
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
const callContext = scrollIntoViewSpy.mock.contexts[0] as HTMLElement;
expect(callContext.id).toBe("comment-comment-2");
expect(callContext.getAttribute("data-comment-id")).toBe("comment-2");
});
it("still scrolls when the timeline is ready before the issue (regression for inbox click)", async () => {
// Reproduces the inbox-click race: timeline data is already in the cache
// (resolved first), but the issue is still pending — so the first render
// sees timeline.length=2 alongside loading=true (skeleton still showing,
// no comment DOM). The scroll effect fires once, fails to find the
// element, and must re-fire when `loading` flips to false. Without
// `loading` in the dep list, that second fire never happens and the
// user lands at the top of the issue.
// Reproduces the inbox-click race: timeline data is in the cache before
// the issue resolves. While `loading` is true the timeline skeleton is
// shown and no comment wrapper is mounted. The deep-link polling loop
// must keep retrying (up to ~320ms of rAFs) until the issue resolves
// and the target's `data-comment-id` appears in the DOM.
let resolveIssue: (value: Issue) => void = () => {};
const issuePromise = new Promise<Issue>((resolve) => {
resolveIssue = resolve;
@@ -588,18 +623,17 @@ describe("IssueDetail (shared)", () => {
renderIssueDetailWithHighlight("comment-2", "issue-1", { seedTimeline: true });
// The skeleton is still showing (issue pending), so even though
// timeline.length>0 the comment DOM is not mounted and no scroll
// can happen yet.
expect(document.getElementById("comment-comment-2")).toBeNull();
expect(
document.querySelector('[data-comment-id="comment-2"]'),
).toBeNull();
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
// Now the issue resolves — comment elements mount, the effect re-runs
// because `loading` is part of its deps, and the scroll fires.
resolveIssue(mockIssue);
await waitFor(() => {
expect(document.getElementById("comment-comment-2")).not.toBeNull();
expect(
document.querySelector('[data-comment-id="comment-2"]'),
).not.toBeNull();
});
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalled();

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
@@ -174,6 +175,47 @@ function shallowEqualEntries(a: TimelineEntry[], b: TimelineEntry[]): boolean {
return true;
}
// Flat per-item shape consumed by <Virtuoso>. Virtuoso needs a flat array
// where each entry is one rendered row; we keep the grouping logic from
// `timelineView.groups` (consecutive same-actor activities still collapse
// into one activity-group row) but project it into a discriminated union
// the itemContent dispatcher can switch on.
type TimelineItem =
| { kind: "comment"; id: string; entry: TimelineEntry }
| { kind: "resolved-bar"; id: string; entry: TimelineEntry }
| { kind: "activity-group"; id: string; entries: TimelineEntry[] };
type RawTimelineGroup = {
type: "comment" | "activities";
entries: TimelineEntry[];
};
function flattenGroups(
groups: ReadonlyArray<RawTimelineGroup>,
expandedResolved: ReadonlySet<string>,
): TimelineItem[] {
const out: TimelineItem[] = [];
for (const group of groups) {
if (group.type === "comment") {
const entry = group.entries[0]!;
const isResolved = !!entry.resolved_at;
const isExpanded = expandedResolved.has(entry.id);
out.push(
isResolved && !isExpanded
? { kind: "resolved-bar", id: entry.id, entry }
: { kind: "comment", id: entry.id, entry },
);
} else {
out.push({
kind: "activity-group",
id: group.entries[0]!.id,
entries: group.entries,
});
}
}
return out;
}
function TimelineSkeleton() {
return (
<div className="mt-4 flex flex-col gap-3">
@@ -352,7 +394,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const [detailsOpen, setDetailsOpen] = useState(true);
const [parentIssueOpen, setParentIssueOpen] = useState(true);
const [tokenUsageOpen, setTokenUsageOpen] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Virtuoso's `customScrollParent` wants the HTMLElement, not a ref. A plain
// `useRef.current` does not trigger a re-render when it populates, so the
// Virtuoso prop would never receive the element. Callback ref + state fixes
// that: setState triggers the re-render that hands Virtuoso the element.
const [scrollContainerEl, setScrollContainerEl] = useState<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
// Per-session: which resolved threads the user has temporarily expanded.
@@ -524,6 +571,40 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
return { threadReplies, groups };
}, [timeline]);
// Flat array consumed by <Virtuoso>. Recomputed when timelineView.groups
// changes (timeline events) or expandedResolved flips (user toggles a
// resolved thread). Kept in a useMemo so Virtuoso's data identity is stable
// across unrelated re-renders.
const items = useMemo<TimelineItem[]>(
() => flattenGroups(timelineView.groups, expandedResolved),
[timelineView.groups, expandedResolved],
);
// Map of reply-comment id → root-comment id, so a deep-link to a reply
// (which lives inside a CommentCard, not in the flat items array) can fall
// back to scrolling the root thread into view. Without this, an inbox
// notification on a reply would land at items[-1] and short-circuit.
const replyToRoot = useMemo(() => {
const map = new Map<string, string>();
for (const [rootId, replies] of timelineView.threadReplies) {
for (const reply of replies) {
map.set(reply.id, rootId);
}
}
return map;
}, [timelineView.threadReplies]);
// Deep-link target index in the flat items array. For root comments this is
// a direct findIndex hit; for reply ids we look up the enclosing root.
const targetIdx = useMemo(() => {
if (!highlightCommentId) return -1;
const direct = items.findIndex((it) => it.id === highlightCommentId);
if (direct >= 0) return direct;
const rootId = replyToRoot.get(highlightCommentId);
if (!rootId) return -1;
return items.findIndex((it) => it.id === rootId);
}, [items, highlightCommentId, replyToRoot]);
const {
reactions: issueReactions,
toggleReaction: handleToggleIssueReaction,
@@ -587,26 +668,129 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const loading = issueLoading;
// Scroll to highlighted comment once both the issue and its timeline are
// available (fire only once per highlightCommentId). `loading` must be in
// the dep list: when timeline.length flips to >0 while the issue itself is
// still loading, the component is still rendering the skeleton, so
// getElementById finds nothing — without re-running on the loading→false
// transition, the scroll silently never happens and the user lands at the
// top of the issue.
// Scroll to highlighted comment once the timeline (and target index) are
// ready. Under virtualization the entry is not in the DOM until Virtuoso
// mounts it, so we ask Virtuoso to scroll the index instead of querying the
// DOM. `initialTopMostItemIndex` on <Virtuoso> handles the rough landing;
// this is the precision pass that runs after ResizeObserver has measured
// real heights and can correct any drift from the estimated landing.
//
// Double rAF: Virtuoso #883 — initialTopMostItemIndex / scrollToIndex are
// racy if applied before the first ResizeObserver pass updates scrollHeight.
// One rAF waits for mount, the nested rAF waits for measurement.
// Highlight flash bumped 2s→3s to outlast mount latency on cold cards.
useEffect(() => {
if (!highlightCommentId || timeline.length === 0 || loading) return;
if (!highlightCommentId || items.length === 0 || targetIdx < 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
didHighlightRef.current = highlightCommentId;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "instant", block: "center" });
// Strategy: Virtuoso's scrollToIndex is asynchronous — it queues an
// internal state change that mounts the target a few frames later. The
// target also isn't in the DOM until Virtuoso settles. So:
// 1. Kick off Virtuoso to start moving toward the target
// 2. Poll for the target's DOM element (max ~20 frames ≈ 320ms)
// 3. Once found, use the browser's scrollIntoView which correctly
// accounts for all sibling content above the list
// 4. After Virtuoso fully settles, scrollIntoView one more time —
// Virtuoso may have overwritten our scroll position
const rafIds: number[] = [];
const timeoutIds: number[] = [];
const cancelled = { value: false };
const findTarget = () => {
// First try the exact id — works for root comments and for replies
// whose enclosing thread is currently expanded.
const direct = scrollContainerEl?.querySelector(
`[data-comment-id="${CSS.escape(highlightCommentId)}"]`,
) as HTMLElement | null;
if (direct) return direct;
// Reply in a collapsed thread: fall back to the root wrapper so we at
// least scroll the card containing the target into view.
const rootId = replyToRoot.get(highlightCommentId);
if (rootId) {
return scrollContainerEl?.querySelector(
`[data-comment-id="${CSS.escape(rootId)}"]`,
) as HTMLElement | null;
}
return null;
};
const performScroll = () => {
const el = findTarget();
if (!el) return false;
el.scrollIntoView({ block: "center", behavior: "auto" });
return true;
};
const pollMount = (attemptsLeft: number) => {
if (cancelled.value) return;
if (performScroll()) {
setHighlightedId(highlightCommentId);
setTimeout(() => setHighlightedId(null), 2000);
didHighlightRef.current = highlightCommentId;
// Done. No reanchor — polling already waits until Virtuoso has
// settled enough to mount the target, so scrollIntoView IS the
// last write. Adding a delayed re-scroll would yank the user back
// if they scrolled away in the meantime.
return;
}
if (attemptsLeft <= 0) {
// Virtuoso didn't mount the target within ~320ms. Still set the
// highlight so when the user manually scrolls there, the card
// flashes. Mark handled so subsequent renders don't keep polling.
// eslint-disable-next-line no-console
console.warn(
`[deep-link] target ${highlightCommentId} did not mount in time; highlight set without scroll`,
);
setHighlightedId(highlightCommentId);
didHighlightRef.current = highlightCommentId;
return;
}
const id = requestAnimationFrame(() => pollMount(attemptsLeft - 1));
rafIds.push(id);
};
const start = requestAnimationFrame(() => {
if (cancelled.value) return;
// Step 1: kick Virtuoso so it begins mounting target index.
virtuosoRef.current?.scrollToIndex({
index: targetIdx,
align: "center",
behavior: "auto",
});
}
}, [highlightCommentId, timeline.length, loading]);
// Step 2-4: start polling for the DOM element.
pollMount(20);
});
rafIds.push(start);
const flash = window.setTimeout(() => setHighlightedId(null), 2000);
timeoutIds.push(flash);
return () => {
cancelled.value = true;
for (const id of rafIds) cancelAnimationFrame(id);
for (const id of timeoutIds) clearTimeout(id);
};
}, [highlightCommentId, items.length, targetIdx, scrollContainerEl]);
// Cmd-F / Ctrl-F on a virtualized timeline only searches what's mounted in
// the viewport — off-screen comments are invisible to browser find-in-page.
// Intercept once per (session, issue) when the list is long enough that the
// user might actually try; let the keystroke pass through on short lists.
// Real fix is in-app search (separate PR); this is the toast stopgap.
useEffect(() => {
if (items.length <= 30) return;
const flagKey = `multica_cmdF_warned:${id}`;
const handler = (e: KeyboardEvent) => {
if (e.key !== "f" || !(e.metaKey || e.ctrlKey)) return;
if (sessionStorage.getItem(flagKey)) return;
e.preventDefault();
sessionStorage.setItem(flagKey, "1");
toast.message(t(($) => $.detail.cmdf_toast_title), {
description: t(($) => $.detail.cmdf_toast_description),
});
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [id, items.length, t]);
const descEditorRef = useRef<ContentEditorRef>(null);
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
@@ -934,7 +1118,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</PageHeader>
{/* Content — scrollable */}
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
<div ref={setScrollContainerEl} className="relative flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
key={`title-${id}`}
@@ -1184,111 +1368,150 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
card is just a header-style "agent is working" anchor. */}
<AgentLiveCard key={id} issueId={id} />
{/* Timeline entries */}
{/* Timeline entries — virtualized via react-virtuoso to keep
first-paint cost O(viewport) instead of O(N). On a 500-comment
issue the unvirtualized .map froze the page for several
seconds (markdown parse + lowlight code highlight runs per
CommentCard on mount).
customScrollParent guard: callback ref populates after the
first commit. Without this null guard Virtuoso falls back to
its own scroller, grabs 0 height inside overflow-y-auto, and
miscomputes total-height on first paint. */}
{timelineLoading && timelineView.groups.length === 0 ? (
<TimelineSkeleton />
) : !scrollContainerEl ? (
// Show skeleton (not blank) while the callback ref populates,
// so the gap between IssueDetail mount and Virtuoso mount feels
// continuous with the loading state instead of flashing empty.
<TimelineSkeleton />
) : (
<>
<div className="mt-4 flex flex-col gap-3">
{timelineView.groups.map((group) => {
if (group.type === "comment") {
const entry = group.entries[0]!;
const isResolved = !!entry.resolved_at;
const isExpanded = expandedResolved.has(entry.id);
if (isResolved && !isExpanded) {
return (
<div key={entry.id} id={`comment-${entry.id}`}>
<ResolvedThreadBar
entry={entry}
replies={timelineView.threadReplies.get(entry.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(entry.id, true)}
/>
</div>
);
}
return (
<div key={entry.id} id={`comment-${entry.id}`}>
<CommentCard
issueId={id}
entry={entry}
replies={timelineView.threadReplies.get(entry.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(entry.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
return (
<div key={group.entries[0]!.id} className="px-4 flex flex-col gap-3">
{group.entries.map((entry, _idx) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
ref={virtuosoRef}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
skipAnimationFrameInResizeObserver
// followOutput intentionally NOT set. Virtuoso treats it as
// a sticky "is at bottom" flag and resets scrollTop to
// maxScrollTop on every ResizeObserver / height-change tick
// — this is what was yanking the user back to scrollTop=299
// whenever they tried to scroll up after a deep-link
// landed on the last item. Issue-detail is document-shaped
// (not a chat), so auto-follow on new comments is not
// critical; users can scroll to bottom themselves.
// Intentionally NOT passing `initialTopMostItemIndex`.
// In customScrollParent mode Virtuoso treats this prop as
// a persistent anchor and resets scrollTop whenever the
// list height changes (ResizeObserver firing on real-card
// measurement, etc.) — which fights against the user when
// they scroll up. Deep-link landing is handled imperatively
// by the useEffect above (scrollToIndex + scrollIntoView).
itemContent={(_i, item) => {
if (item.kind === "resolved-bar") {
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{/* Coalesce badge for non-task actions: task_completed / task_failed already
bake the count into their translation, so suppress the badge there to
avoid showing "×N" twice. */}
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
// data-comment-id is the anchor for inbox deep-link;
// see the deep-link useEffect for how scrollIntoView
// finds it after Virtuoso mounts the item.
<div className="pb-3" data-comment-id={item.id}>
<ResolvedThreadBar
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(item.id, true)}
/>
</div>
);
})}
</div>
);
})}
</div>
</>
}
if (item.kind === "comment") {
const isResolved = !!item.entry.resolved_at;
return (
<div className="pb-3" data-comment-id={item.id}>
<CommentCard
issueId={id}
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
// activity-group
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
}}
/>
</div>
)}
{/* Bottom comment input — no avatar, full width */}
<div className="mt-4">
<CommentInput issueId={id} onSubmit={submitComment} />
{/* key={id}: web's /issues/[id] route doesn't remount on
issueId change, so without an explicit key the editor
keeps the previous issue's in-memory content and the
next keystroke would flush it into the new issue's
draft key. */}
<CommentInput key={id} issueId={id} onSubmit={submitComment} />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { useRef, useState, useCallback, useEffect } from "react";
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
@@ -8,6 +8,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { ActorAvatar } from "../../common/actor-avatar";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { useCommentDraftStore, type CommentDraftKey } from "@multica/core/issues/stores";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
@@ -22,6 +23,10 @@ interface ReplyInputProps {
avatarId: string;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
/** When set, hydrates/persists the in-progress reply via the draft store.
* Required for replies inside virtualized timeline threads, where the
* enclosing CommentCard may unmount on scroll-out. */
draftKey?: CommentDraftKey;
}
// ---------------------------------------------------------------------------
@@ -35,11 +40,19 @@ function ReplyInput({
avatarId,
onSubmit,
size = "default",
draftKey,
}: ReplyInputProps) {
const { t } = useT("issues");
const placeholderText = placeholder ?? t(($) => $.reply.placeholder);
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
// If a draft key is provided, hydrate from store on mount (defaultValue is
// the only injection point on ContentEditorRef) and flush on every onUpdate.
const initialDraft = draftKey
? useCommentDraftStore.getState().getDraft(draftKey)
: undefined;
const setDraft = useCommentDraftStore((s) => s.setDraft);
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
const [isEmpty, setIsEmpty] = useState(!initialDraft?.trim());
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
@@ -48,6 +61,22 @@ function ReplyInput({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
// Flush on tab close / mobile background — same rationale as CommentInput.
useEffect(() => {
if (!draftKey) return;
const flush = () => {
const md = editorRef.current?.getMarkdown();
if (md && md.trim().length > 0) setDraft(draftKey, md);
};
const onVis = () => { if (document.visibilityState === "hidden") flush(); };
document.addEventListener("visibilitychange", onVis);
window.addEventListener("pagehide", flush);
return () => {
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("pagehide", flush);
};
}, [draftKey, setDraft]);
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
@@ -70,6 +99,7 @@ function ReplyInput({
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
if (draftKey) clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -98,8 +128,15 @@ function ReplyInput({
<div className="flex-1 min-h-0 overflow-y-auto">
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}
placeholder={placeholderText}
onUpdate={(md) => setIsEmpty(!md.trim())}
onUpdate={(md) => {
setIsEmpty(!md.trim());
if (draftKey) {
if (md.trim().length > 0) setDraft(draftKey, md);
else clearDraft(draftKey);
}
}}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}

View File

@@ -126,6 +126,8 @@
"pin_tooltip": "Pin to sidebar",
"unpin_tooltip": "Unpin from sidebar",
"sidebar_tooltip": "Toggle sidebar",
"cmdf_toast_title": "Find on page only covers what's visible",
"cmdf_toast_description": "The timeline is virtualized — ⌘F can't see off-screen comments. Full in-app search is coming.",
"update_failed": "Failed to update issue",
"link_copied": "Link copied",
"link_copy_failed": "Failed to copy link",

View File

@@ -125,6 +125,8 @@
"pin_tooltip": "固定到侧边栏",
"unpin_tooltip": "从侧边栏取消固定",
"sidebar_tooltip": "切换侧边栏",
"cmdf_toast_title": "页面内查找仅覆盖可见区",
"cmdf_toast_description": "评论列表已虚拟滚动,⌘F 找不到视口外的评论。完整应用内搜索即将推出。",
"update_failed": "更新 issue 失败",
"link_copied": "已复制链接",
"link_copy_failed": "复制链接失败",

View File

@@ -80,6 +80,7 @@
"motion": "^12.38.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.5",
"react-virtuoso": "catalog:",
"recharts": "3.8.0",
"rehype-katex": "catalog:",
"rehype-raw": "^7.0.0",

17
pnpm-lock.yaml generated
View File

@@ -69,6 +69,9 @@ catalogs:
react-i18next:
specifier: ^17.0.6
version: 17.0.6
react-virtuoso:
specifier: ^4.14.0
version: 4.18.7
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
@@ -799,6 +802,9 @@ importers:
react-resizable-panels:
specifier: ^4.7.5
version: 4.7.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-virtuoso:
specifier: 'catalog:'
version: 4.18.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
recharts:
specifier: 3.8.0
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
@@ -6521,6 +6527,12 @@ packages:
'@types/react':
optional: true
react-virtuoso@4.18.7:
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
@@ -14053,6 +14065,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-virtuoso@4.18.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react@19.2.3: {}
read-binary-file-arch@1.0.6:

View File

@@ -46,6 +46,9 @@ catalog:
# Loading animations (chat StatusPill)
unicode-animations: "^1.0.3"
# Virtualized timeline (issue detail comments)
react-virtuoso: "^4.14.0"
# Product analytics
posthog-js: "^1.176.1"