mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
* 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>
74 lines
2.5 KiB
TypeScript
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),
|
|
],
|
|
};
|
|
}
|