mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
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:
80
packages/core/issues/stores/comment-draft-store.ts
Normal file
80
packages/core/issues/stores/comment-draft-store.ts
Normal 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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "复制链接失败",
|
||||
|
||||
@@ -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
17
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user