Files
multica/packages/core/issues/timeline-cache.ts
Naiyuan Qing ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

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

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:06 +08:00

74 lines
2.5 KiB
TypeScript

import type { InfiniteData } from "@tanstack/react-query";
import type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
} from "../types";
/** Shape of the cursor-paginated timeline cache. Exported so consumers (the
* hook, mutations, tests) all reference the same type. */
export type TimelineCacheData = InfiniteData<TimelinePage, TimelinePageParam>;
/** Map fn over every entry across every page, preserving page identity for
* any page whose entries don't change so React.memo on CommentCard isn't
* defeated by gratuitous reference churn. */
export function mapAllEntries(
data: TimelineCacheData | undefined,
fn: (e: TimelineEntry) => TimelineEntry,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
let entriesChanged = false;
const entries = page.entries.map((e) => {
const next = fn(e);
if (next !== e) entriesChanged = true;
return next;
});
if (!entriesChanged) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Filter out entries matching the predicate from every page. */
export function filterAllEntries(
data: TimelineCacheData | undefined,
predicate: (e: TimelineEntry) => boolean,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
const entries = page.entries.filter((e) => !predicate(e));
if (entries.length === page.entries.length) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Prepend a new entry to the latest page (pages[0]). Caller must verify
* the cache is at-latest before calling — otherwise the entry is hidden
* behind a "show newer" gap and shouldn't be injected. Returns the data
* unchanged if the cache is not at-latest or the entry already exists. */
export function prependToLatestPage(
data: TimelineCacheData | undefined,
entry: TimelineEntry,
): TimelineCacheData | undefined {
if (!data || data.pages.length === 0) return data;
const first = data.pages[0];
if (!first) return data;
if (first.has_more_after) return data; // not at latest; skip silently
if (first.entries.some((e) => e.id === entry.id)) return data;
return {
...data,
pages: [
{ ...first, entries: [entry, ...first.entries] },
...data.pages.slice(1),
],
};
}