diff --git a/packages/core/issues/stores/comment-draft-store.ts b/packages/core/issues/stores/comment-draft-store.ts new file mode 100644 index 0000000000..cf30a3bcd3 --- /dev/null +++ b/packages/core/issues/stores/comment-draft-store.ts @@ -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; + 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): Record { + const cutoff = Date.now() - TTL_MS; + const out: Record = {}; + 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()( + 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()); diff --git a/packages/core/issues/stores/index.ts b/packages/core/issues/stores/index.ts index a62c974b72..148d46f951 100644 --- a/packages/core/issues/stores/index.ts +++ b/packages/core/issues/stores/index.ts @@ -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, diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index e897bcb866..29d9fe0720 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -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({
$.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({
$.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) => ( -
+
onReply(entry.id, content, attachmentIds)} />
diff --git a/packages/views/issues/components/comment-input.tsx b/packages/views/issues/components/comment-input.tsx index a05ab38b37..739293364a 100644 --- a/packages/views/issues/components/comment-input.tsx +++ b/packages/views/issues/components/comment-input.tsx @@ -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(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>(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) {
$.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} diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index a6e7afb4f5..82d07840a0 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -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, + getDraft: () => undefined, + setDraft: () => {}, + clearDraft: () => {}, + }; + return selector ? selector(state) : state; + }, + { + getState: () => ({ + drafts: {} as Record, + 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 }) => ( +
+ {data.map((item, i) => ( +
{itemContent(i, item) as React.ReactElement}
+ ))} +
+ ), })); // 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((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(); diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index ab87e063d1..318dffa29b 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -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 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, + expandedResolved: ReadonlySet, +): 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 (
@@ -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(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(null); + const virtuosoRef = useRef(null); const [highlightedId, setHighlightedId] = useState(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 . 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( + () => 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(); + 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 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(null); const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({ @@ -934,7 +1118,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr {/* Content — scrollable */} -
+
- {/* 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 ? ( + ) : !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. + ) : ( - <> -
- {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 ( -
- toggleResolvedExpand(entry.id, true)} - /> -
- ); - } - return ( -
- toggleResolvedExpand(entry.id, false) : undefined} - highlightedCommentId={highlightedId} - /> -
- ); - } - - return ( -
- {group.entries.map((entry, _idx) => { - const details = (entry.details ?? {}) as Record; - 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 = ; - } else if (isPriorityChange && details.to) { - leadIcon = ; - } else if (isDueDateChange) { - leadIcon = ; - } else { - leadIcon = ; - } - +
+ `${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 ( -
-
- {leadIcon} -
-
- {getActorName(entry.actor_type, entry.actor_id)} - {formatActivity(entry, t, getActorName)} - {/* 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" && ( - - {t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })} - - )} - - - {timeAgo(entry.created_at)} - - } - /> - - {new Date(entry.created_at).toLocaleString()} - - -
+ // 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. +
+ toggleResolvedExpand(item.id, true)} + />
); - })} -
- ); - })} -
- + } + if (item.kind === "comment") { + const isResolved = !!item.entry.resolved_at; + return ( +
+ toggleResolvedExpand(item.id, false) : undefined} + highlightedCommentId={highlightedId} + /> +
+ ); + } + // activity-group + return ( +
+ {item.entries.map((entry) => { + const details = (entry.details ?? {}) as Record; + 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 = ; + } else if (isPriorityChange && details.to) { + leadIcon = ; + } else if (isDueDateChange) { + leadIcon = ; + } else { + leadIcon = ; + } + + return ( +
+
+ {leadIcon} +
+
+ {getActorName(entry.actor_type, entry.actor_id)} + {formatActivity(entry, t, getActorName)} + {(entry.coalesced_count ?? 1) > 1 && + entry.action !== "task_completed" && + entry.action !== "task_failed" && ( + + {t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })} + + )} + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + +
+
+ ); + })} +
+ ); + }} + /> +
)} {/* Bottom comment input — no avatar, full width */}
- + {/* 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. */} +
diff --git a/packages/views/issues/components/reply-input.tsx b/packages/views/issues/components/reply-input.tsx index cb105165e3..5e64f6dc09 100644 --- a/packages/views/issues/components/reply-input.tsx +++ b/packages/views/issues/components/reply-input.tsx @@ -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; 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(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>(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({
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} diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 581d718902..53cceeec39 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -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", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index a1149de58c..b0ba10c249 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -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": "复制链接失败", diff --git a/packages/views/package.json b/packages/views/package.json index a134b5a8b8..2a9764aba5 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a87bf259c..21e676d6a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4fe10ef80b..31e0c248e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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"