mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/deep-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a34e8a42f |
@@ -15,7 +15,7 @@
|
||||
* the page.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
@@ -113,6 +113,60 @@ function getMermaidLayout(svg: string): MermaidLayout {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Default skeleton height while Mermaid loads + renders for the first time
|
||||
// in this session. Picked to absorb most issue-detail diagrams without
|
||||
// excessive empty space; web.dev's CLS guidance recommends reserving any
|
||||
// such space upfront so async content doesn't shift surrounding layout.
|
||||
const MERMAID_SKELETON_HEIGHT_PX = 280;
|
||||
const MERMAID_LAYOUT_CACHE_PREFIX = "multica:mermaid:layout:";
|
||||
|
||||
// DJB2 — small, fast, sufficient for sessionStorage cache keys. The chart
|
||||
// text itself is too unwieldy as a key (length, special chars), and a
|
||||
// crypto-strength hash would have to be async.
|
||||
function hashChart(chart: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < chart.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ chart.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function readCachedLayout(chart: string): MermaidLayout | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(
|
||||
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
|
||||
);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed?.width === "number" &&
|
||||
typeof parsed?.height === "number" &&
|
||||
parsed.width > 0 &&
|
||||
parsed.height > 0
|
||||
) {
|
||||
return { width: parsed.width, height: parsed.height };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedLayout(chart: string, layout: MermaidLayout): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!layout.width || !layout.height) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
|
||||
JSON.stringify({ width: layout.width, height: layout.height }),
|
||||
);
|
||||
} catch {
|
||||
// Quota exceeded or storage disabled — degrade silently; we still
|
||||
// render correctly, just without the zero-shift optimisation.
|
||||
}
|
||||
}
|
||||
|
||||
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
@@ -200,7 +254,11 @@ export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const themeVersion = useThemeVersion();
|
||||
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
|
||||
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<MermaidLayout>({});
|
||||
// Lazy initial value: if we've rendered this exact chart already in the
|
||||
// current session, the cached layout lets us reserve correct space on the
|
||||
// very first paint — eliminating the 0px → real-height shift that breaks
|
||||
// deep-link scroll positioning and ambient reading position.
|
||||
const [layout, setLayout] = useState<MermaidLayout>(() => readCachedLayout(chart) ?? {});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
@@ -212,7 +270,10 @@ export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
setError(null);
|
||||
setSandboxedDocument(null);
|
||||
setExpandedDocument(null);
|
||||
setLayout({});
|
||||
// Seed layout from cache (if any) so the skeleton sizes correctly
|
||||
// even when `chart` changes after mount — the lazy useState above
|
||||
// only fires once.
|
||||
setLayout(readCachedLayout(chart) ?? {});
|
||||
const mermaid = await getMermaid();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -222,7 +283,9 @@ export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
});
|
||||
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
|
||||
if (!cancelled) {
|
||||
setLayout(getMermaidLayout(renderedSvg));
|
||||
const measured = getMermaidLayout(renderedSvg);
|
||||
setLayout(measured);
|
||||
writeCachedLayout(chart, measured);
|
||||
setSandboxedDocument(
|
||||
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
@@ -255,8 +318,21 @@ export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// While the iframe is not yet ready, hold the container at the skeleton
|
||||
// height (cached real height when available, fallback default otherwise).
|
||||
// Once the iframe renders, drop the min-height — the iframe's own height
|
||||
// drives layout. If the cache was right, this transition is zero-shift.
|
||||
const containerStyle: CSSProperties | undefined = sandboxedDocument
|
||||
? undefined
|
||||
: { minHeight: layout.height ?? MERMAID_SKELETON_HEIGHT_PX };
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="mermaid-diagram"
|
||||
aria-label="Mermaid diagram"
|
||||
style={containerStyle}
|
||||
>
|
||||
{sandboxedDocument ? (
|
||||
<>
|
||||
<iframe
|
||||
|
||||
@@ -663,7 +663,7 @@ function CommentCardImpl({
|
||||
|
||||
{/* Replies */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<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")}>
|
||||
<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")}>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
|
||||
@@ -287,14 +287,14 @@ vi.mock("@multica/core/issues/stores", () => ({
|
||||
|
||||
// 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.
|
||||
// inline so id="comment-..." nodes are always present in the DOM — this
|
||||
// matches the production cold-path where `initialItemCount` force-mounts
|
||||
// items[0..targetIdx], giving the native scrollIntoView a real target.
|
||||
//
|
||||
// scrollToIndexSpy: the deep-link logic now uses Virtuoso's own
|
||||
// scrollToIndex API instead of native el.scrollIntoView (native diverges
|
||||
// from Virtuoso's internal scrollTop model, petyosi #1083). The mock
|
||||
// exposes a spy so we can assert deep-link landing in tests.
|
||||
const scrollToIndexSpy = vi.hoisted(() => vi.fn());
|
||||
// scrollIntoViewSpy: we spy on Element.prototype.scrollIntoView (jsdom no-ops
|
||||
// it by default) so tests can assert the deep-link effect dispatched a
|
||||
// native scroll on the target node.
|
||||
const scrollIntoViewSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("react-virtuoso", () => ({
|
||||
Virtuoso: forwardRef(function MockVirtuoso(
|
||||
@@ -302,9 +302,10 @@ vi.mock("react-virtuoso", () => ({
|
||||
ref: any,
|
||||
) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: scrollToIndexSpy,
|
||||
// Real Virtuoso exposes more, but the deep-link path only needs
|
||||
// scrollToIndex. Other call sites would fail loudly if added later.
|
||||
// Real Virtuoso ref methods are not exercised by tests in this file
|
||||
// since the cold-path uses native scrollIntoView on the DOM node.
|
||||
scrollIntoView: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
<div data-testid="virtuoso-mock">
|
||||
@@ -316,6 +317,17 @@ vi.mock("react-virtuoso", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// jsdom's HTMLElement.prototype.scrollIntoView is a no-op stub; replace it
|
||||
// with a spy so the deep-link effect's call can be observed.
|
||||
beforeEach(() => {
|
||||
scrollIntoViewSpy.mockClear();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: scrollIntoViewSpy,
|
||||
});
|
||||
});
|
||||
|
||||
// Mock modals
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
@@ -597,30 +609,26 @@ describe("IssueDetail (shared)", () => {
|
||||
});
|
||||
|
||||
describe("highlightCommentId scroll-to-comment", () => {
|
||||
beforeEach(() => {
|
||||
scrollToIndexSpy.mockClear();
|
||||
});
|
||||
|
||||
it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => {
|
||||
renderIssueDetailWithHighlight("comment-2");
|
||||
|
||||
// Wait for the comment row to mount under the virtuoso mock.
|
||||
// Wait for the comment row to mount. With initialItemCount in
|
||||
// production, items[0..targetIdx] are force-mounted on first commit;
|
||||
// the mock unconditionally inline-renders every item, so this just
|
||||
// waits for the regular render pass.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-comment-id="comment-2"]'),
|
||||
document.getElementById("comment-comment-2"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
// The deep-link effect calls virtuosoRef.scrollToIndex with the
|
||||
// target's index. comment-2 is items[1] in the flat timeline
|
||||
// (items[0] = comment-1, items[1] = comment-2). The effect calls
|
||||
// scrollToIndex twice (once on enter, once after the settle
|
||||
// timeout); we only need to see at least one call land.
|
||||
// The deep-link useLayoutEffect calls native scrollIntoView on the
|
||||
// target node ({block: 'center'}).
|
||||
await waitFor(() => {
|
||||
expect(scrollToIndexSpy).toHaveBeenCalled();
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(scrollToIndexSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 1, align: "center" }),
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ block: "center" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -628,9 +636,8 @@ describe("IssueDetail (shared)", () => {
|
||||
// Reproduces the inbox-click race: timeline data is in the cache
|
||||
// before the issue resolves. While loading is true, IssueDetail
|
||||
// renders the loading skeleton (Virtuoso never mounts), so no
|
||||
// scrollToIndex can fire. After the issue resolves, Virtuoso
|
||||
// mounts, the bootstrapRef capture path or the warm-path effect
|
||||
// fires scrollToIndex with the target index.
|
||||
// scroll can fire. After the issue resolves, Virtuoso mounts and
|
||||
// the useLayoutEffect dispatches the native scroll.
|
||||
let resolveIssue: (value: Issue) => void = () => {};
|
||||
const issuePromise = new Promise<Issue>((resolve) => {
|
||||
resolveIssue = resolve;
|
||||
@@ -640,20 +647,77 @@ describe("IssueDetail (shared)", () => {
|
||||
renderIssueDetailWithHighlight("comment-2", "issue-1", { seedTimeline: true });
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-comment-id="comment-2"]'),
|
||||
document.getElementById("comment-comment-2"),
|
||||
).toBeNull();
|
||||
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
||||
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
|
||||
|
||||
resolveIssue(mockIssue);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-comment-id="comment-2"]'),
|
||||
document.getElementById("comment-comment-2"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(scrollToIndexSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 1, align: "center" }),
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ block: "center" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-expands a folded resolved thread when deep-link target is a reply inside it", async () => {
|
||||
// Seed a timeline where comment-3 is resolved (so it renders as a
|
||||
// resolved-bar by default) and has a reply, reply-1, whose id is the
|
||||
// deep-link target. The reply is not in the flat items array — only
|
||||
// the resolved-bar root is. The effect must detect this, expand the
|
||||
// thread, then on re-run scroll to the reply's id="comment-reply-1" node.
|
||||
const timelineWithResolvedThread: TimelineEntry[] = [
|
||||
...mockTimeline,
|
||||
{
|
||||
type: "comment",
|
||||
id: "comment-3",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
content: "Resolved root",
|
||||
parent_id: null,
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
comment_type: "comment",
|
||||
resolved_at: "2026-01-19T00:00:00Z",
|
||||
} as TimelineEntry,
|
||||
{
|
||||
type: "comment",
|
||||
id: "reply-1",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
content: "Reply inside resolved thread",
|
||||
parent_id: "comment-3",
|
||||
created_at: "2026-01-18T01:00:00Z",
|
||||
updated_at: "2026-01-18T01:00:00Z",
|
||||
comment_type: "comment",
|
||||
} as TimelineEntry,
|
||||
];
|
||||
mockApiObj.listTimeline.mockResolvedValue(timelineWithResolvedThread);
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail issueId="issue-1" highlightCommentId="reply-1" />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// After expansion, the reply must appear in the DOM (inside the now
|
||||
// -unfolded CommentCard) and the deep-link effect must scroll to it.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.getElementById("comment-reply-1"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ block: "center" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { useNavigation } from "../../navigation";
|
||||
@@ -399,7 +399,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
// 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.
|
||||
@@ -668,83 +667,44 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// First-paint deep-link bootstrap. Captured exactly once, on the first
|
||||
// render where items[] is populated. Why a ref rather than a lazy
|
||||
// useState initializer: IssueDetail mounts long before timeline data
|
||||
// resolves (items.length is 0), so a lazy useState would freeze in
|
||||
// the "no bootstrap" state forever. The ref follows React's documented
|
||||
// "avoid recreating ref contents" idiom (the Video example in the
|
||||
// useRef docs): write synchronously in render, gated by a one-shot
|
||||
// flag so the value can't be overwritten on later renders.
|
||||
// Deep-link landing. Semantically equivalent to navigating to
|
||||
// `#comment-${id}`: find the element with that id, scrollIntoView it.
|
||||
// When `highlightCommentId` is set the timeline below renders flat (no
|
||||
// virtualization), so every comment id is in the DOM by the time this
|
||||
// effect runs after commit.
|
||||
//
|
||||
// Passed to <Virtuoso initialTopMostItemIndex>: the only position
|
||||
// anchor that runs *before* first paint, dodging the post-mount
|
||||
// scrollToIndex race (Virtuoso #883). Subsequent deep-links inside
|
||||
// the same mount (user clicks a second inbox notification on the
|
||||
// same issue) go through scrollToIndex in the effect below — the
|
||||
// bootstrap value is only consumed on cold start.
|
||||
const bootstrapRef = useRef<{
|
||||
resolved: boolean;
|
||||
value?: { index: number; align: "center" };
|
||||
}>({ resolved: false });
|
||||
if (!bootstrapRef.current.resolved && items.length > 0) {
|
||||
bootstrapRef.current = {
|
||||
resolved: true,
|
||||
value:
|
||||
highlightCommentId && targetIdx >= 0
|
||||
? { index: targetIdx, align: "center" }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
const initialBootstrap = bootstrapRef.current.value;
|
||||
|
||||
// Deep-link landing. Virtualization makes "land precisely" not a
|
||||
// single operation but a convergence: Virtuoso first uses estimated
|
||||
// spacer heights to scroll to the target, mounts viewport items, the
|
||||
// ResizeObserver fires real measurements, spacer heights update, and
|
||||
// scrollTop is corrected. Markdown render and lowlight code highlight
|
||||
// can reflow items again later in the same frame, triggering another
|
||||
// round of correction. Trying to outsmart this with a single
|
||||
// perfectly-timed scroll is what made the previous two attempts both
|
||||
// complex and unreliable.
|
||||
// For a reply inside a folded resolved thread, the reply is not in items
|
||||
// (only the resolved-bar root is). Auto-expand the thread first; the
|
||||
// effect re-runs once items re-flatten.
|
||||
//
|
||||
// This effect cooperates with Virtuoso's correction loop instead of
|
||||
// fighting it: schedule three scrollToIndex calls — immediate, 120ms
|
||||
// (after the first measurement pass), 500ms (after markdown/lowlight
|
||||
// settle). Each call uses whatever spacer heights are current, so
|
||||
// the convergence narrows on each pass. Visually this is a single
|
||||
// instant scroll with at most a few pixels of late re-centering —
|
||||
// not a re-jump, because each pass starts from the previous result.
|
||||
//
|
||||
// virtuosoRef.scrollToIndex (not native el.scrollIntoView) keeps
|
||||
// Virtuoso's internal scrollTop model consistent (petyosi #1083).
|
||||
// `scrollContainerEl` is in deps because the component early-returns a
|
||||
// loading skeleton while the issue query is pending. The scroll-container
|
||||
// ref populates only on the post-loading render, so it's the signal that
|
||||
// the timeline (and the deep-link target id) has actually rendered.
|
||||
useEffect(() => {
|
||||
if (!highlightCommentId || items.length === 0 || targetIdx < 0) return;
|
||||
if (!scrollContainerEl) return;
|
||||
if (!highlightCommentId || items.length === 0) return;
|
||||
if (didHighlightRef.current === highlightCommentId) return;
|
||||
|
||||
const rootId = replyToRoot.get(highlightCommentId);
|
||||
if (
|
||||
rootId &&
|
||||
rootId !== highlightCommentId &&
|
||||
items[targetIdx]?.kind === "resolved-bar"
|
||||
) {
|
||||
toggleResolvedExpand(rootId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.getElementById(`comment-${highlightCommentId}`);
|
||||
if (!el) return;
|
||||
|
||||
didHighlightRef.current = highlightCommentId;
|
||||
|
||||
const land = () =>
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: targetIdx,
|
||||
align: "center",
|
||||
behavior: "auto",
|
||||
});
|
||||
|
||||
land();
|
||||
const t1 = window.setTimeout(land, 120);
|
||||
const t2 = window.setTimeout(land, 500);
|
||||
el.scrollIntoView({ block: "center" });
|
||||
|
||||
setHighlightedId(highlightCommentId);
|
||||
const fade = window.setTimeout(() => setHighlightedId(null), 2500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(fade);
|
||||
};
|
||||
}, [highlightCommentId, items.length, targetIdx, scrollContainerEl]);
|
||||
return () => clearTimeout(fade);
|
||||
}, [highlightCommentId, items, targetIdx, scrollContainerEl, replyToRoot, toggleResolvedExpand]);
|
||||
|
||||
// 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.
|
||||
@@ -979,6 +939,97 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
</div>
|
||||
);
|
||||
|
||||
// Shared row renderer for both timeline render modes (flat / virtualized).
|
||||
// The wrapper `id="comment-..."` is the deep-link target — equivalent to
|
||||
// a native `<a href="#comment-...">` anchor.
|
||||
const renderItem = (_i: number, item: TimelineItem): React.ReactElement => {
|
||||
if (item.kind === "resolved-bar") {
|
||||
return (
|
||||
<div className="pb-3" id={`comment-${item.id}`}>
|
||||
<ResolvedThreadBar
|
||||
entry={item.entry}
|
||||
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
|
||||
onExpand={() => toggleResolvedExpand(item.id, true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "comment") {
|
||||
const isResolved = !!item.entry.resolved_at;
|
||||
return (
|
||||
<div className="pb-3" id={`comment-${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>
|
||||
);
|
||||
};
|
||||
|
||||
const detailContent = (
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<PageHeader className="gap-2 bg-background text-sm">
|
||||
@@ -1092,16 +1143,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Content — scrollable. `overflow-anchor: none` disables the
|
||||
browser's built-in scroll-anchoring so that late layout shifts
|
||||
inside the virtualized timeline (Virtuoso resizing list items
|
||||
above the viewport, images inside comments resolving their
|
||||
natural size, etc.) don't silently nudge scrollTop and undo
|
||||
the deep-link scroll we just performed. */}
|
||||
<div
|
||||
ref={setScrollContainerEl}
|
||||
className="relative flex-1 overflow-y-auto"
|
||||
style={{ overflowAnchor: "none" }}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||
<TitleEditor
|
||||
@@ -1364,137 +1408,55 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
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">
|
||||
<Virtuoso
|
||||
key={`${wsId}:${id}`}
|
||||
ref={virtuosoRef}
|
||||
customScrollParent={scrollContainerEl}
|
||||
data={items}
|
||||
increaseViewportBy={{ top: 800, bottom: 800 }}
|
||||
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
|
||||
skipAnimationFrameInResizeObserver
|
||||
// First-paint anchor for inbox deep-link. Only ever
|
||||
// populated on initial mount (see `initialBootstrap` /
|
||||
// `bootstrapRef` above); subsequent re-renders pass the
|
||||
// same value, so Virtuoso #458 (this prop acting as a
|
||||
// persistent anchor that resets scrollTop on height
|
||||
// changes) doesn't fire. Warm-path deep-links — user
|
||||
// clicks a second inbox notification on the same issue
|
||||
// — go through scrollToIndex in the effect above.
|
||||
//
|
||||
// Spread-on-defined: passing `initialTopMostItemIndex
|
||||
// ={undefined}` triggers a runtime crash inside
|
||||
// react-virtuoso ("Cannot read properties of undefined
|
||||
// (reading 'index')") because the library accesses
|
||||
// `.index` on the prop without a null guard. Omitting
|
||||
// the prop entirely takes the library's default path.
|
||||
{...(initialBootstrap && { initialTopMostItemIndex: initialBootstrap })}
|
||||
// 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.
|
||||
itemContent={(_i, item) => {
|
||||
if (item.kind === "resolved-bar") {
|
||||
return (
|
||||
// data-comment-id retained for any external code
|
||||
// (tests, debugging, future deep-link variants)
|
||||
// that wants to find a comment node directly.
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
// Two render modes:
|
||||
// - `highlightCommentId` set (came from inbox deep-link) →
|
||||
// render flat. Every comment mounts, every height is real,
|
||||
// the target id is in the DOM the instant the useEffect
|
||||
// above runs `scrollIntoView`. No virtualization estimate
|
||||
// errors, no spacer reflow drift. Pays cold-mount cost
|
||||
// proportional to items.length (markdown + lowlight per
|
||||
// comment), which is acceptable in the deep-link case —
|
||||
// the user has explicit intent to land on a specific item.
|
||||
// - otherwise → Virtuoso. Browsing mode, virtualization
|
||||
// wins on first-paint perf for long timelines.
|
||||
//
|
||||
// The split is deliberate: virtualization and "land precisely
|
||||
// on a target" have fundamentally opposed contracts (estimated
|
||||
// heights vs real heights). Trying to satisfy both in one
|
||||
// path is what produced the bug history this PR closes.
|
||||
!highlightCommentId ? (
|
||||
!scrollContainerEl ? (
|
||||
// Skeleton while the callback ref populates so the gap
|
||||
// between IssueDetail mount and Virtuoso mount doesn't
|
||||
// flash empty.
|
||||
<TimelineSkeleton />
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<Virtuoso
|
||||
key={`${wsId}:${id}`}
|
||||
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 height-change
|
||||
// tick — issue-detail is document-shaped, not chat.
|
||||
itemContent={renderItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={`${item.kind}:${item.id}`}>
|
||||
{renderItem(i, item)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Bottom comment input — no avatar, full width */}
|
||||
|
||||
Reference in New Issue
Block a user