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>
203 lines
7.2 KiB
TypeScript
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),
|
|
});
|
|
}
|