Files
multica/packages/core/issues/queries.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

203 lines
7.2 KiB
TypeScript

import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
IssueStatus,
ListIssuesParams,
ListIssuesCache,
TimelinePage,
TimelinePageParam,
} from "../types";
import { BOARD_STATUSES } from "./config";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
/** Per-scope "my issues" list with filter identity baked into the key. */
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
/**
* Cursor-paginated timeline cache. Around-mode lookups use a separate cache
* (keyed by the anchor id) so an Inbox-jump fetch does not pollute the
* default latest-page cache that the regular issue list path consumes.
*/
timeline: (issueId: string, around?: string | null) =>
around
? (["issues", "timeline", issueId, "around", around] as const)
: (["issues", "timeline", issueId] as const),
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
/** Per-issue task list (issue-detail Execution log section). */
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
/** Prefix-match key for invalidating tasks across all issues — used by
* the global WS task: prefix path so any task lifecycle event refreshes
* every per-issue list, regardless of which issue is currently mounted. */
tasksAll: () => ["issues", "tasks"] as const,
};
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
export function flattenIssueBuckets(data: ListIssuesCache) {
const out = [];
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) out.push(...bucket.issues);
}
return out;
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
),
);
const byStatus: ListIssuesCache["byStatus"] = {};
PAGINATED_STATUSES.forEach((status, i) => {
const res = responses[i]!;
byStatus[status] = { issues: res.issues, total: res.total };
});
return { byStatus };
}
/**
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
* by status, each with `{ issues, total }`), and `select` flattens it to
* `Issue[]` for consumers. Mutations and ws-updaters must use
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
*
* Fetches the first page of each paginated status in parallel. Use
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: () => fetchFirstPages(),
select: flattenIssueBuckets,
});
}
/**
* Server-filtered issue list for the My Issues page.
* Each scope gets its own cache entry so switching tabs is instant after first load.
*/
export function myIssueListOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: flattenIssueBuckets,
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),
queryFn: () => api.getIssue(id),
});
}
export function childIssueProgressOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.childProgress(wsId),
queryFn: () => api.getChildIssueProgress(),
select: (data) => {
const map = new Map<string, { done: number; total: number }>();
for (const entry of data.progress) {
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
}
return map;
},
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
});
}
/**
* Infinite-query options for the cursor-paginated timeline. The first page is
* either the latest 50 entries (no `around`) or a 50-wide window centered on
* the given comment/activity id (Inbox jump path). `getNextPageParam` walks
* older; `getPreviousPageParam` walks newer.
*/
export function issueTimelineInfiniteOptions(
issueId: string,
around?: string | null,
) {
return infiniteQueryOptions<
TimelinePage,
Error,
{ pages: TimelinePage[]; pageParams: TimelinePageParam[] },
readonly unknown[],
TimelinePageParam
>({
queryKey: issueKeys.timeline(issueId, around ?? null),
initialPageParam: around
? ({ mode: "around", id: around } as TimelinePageParam)
: ({ mode: "latest" } as TimelinePageParam),
queryFn: ({ pageParam }) => api.listTimeline(issueId, pageParam),
// Walk older: append a page below the current oldest (last entry of the
// last loaded page). undefined = no more older entries.
getNextPageParam: (lastPage) =>
lastPage.has_more_before && lastPage.next_cursor
? ({ mode: "before", cursor: lastPage.next_cursor } as TimelinePageParam)
: undefined,
// Walk newer: prepend a page above the current newest (first entry of the
// first loaded page). undefined = at the latest, no newer to fetch.
getPreviousPageParam: (firstPage) =>
firstPage.has_more_after && firstPage.prev_cursor
? ({ mode: "after", cursor: firstPage.prev_cursor } as TimelinePageParam)
: undefined,
});
}
export function issueReactionsOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.reactions(issueId),
queryFn: async () => {
const issue = await api.getIssue(issueId);
return issue.reactions ?? [];
},
});
}
export function issueSubscribersOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.subscribers(issueId),
queryFn: () => api.listIssueSubscribers(issueId),
});
}
export function issueUsageOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.usage(issueId),
queryFn: () => api.getIssueUsage(issueId),
});
}