mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929) The cursor-paginated /timeline and /comments endpoints were sized for a problem the data shape doesn't have: prod p99 is ~30 comments per issue and the all-time max is ~1.1k. Time-based pagination also splits reply threads across page boundaries (orphan replies), which the frontend was papering over with an "orphan rescue" that promoted disconnected replies to top-level — confusing UX with no real benefit. Replace both endpoints with a single full-issue fetch, capped server-side at 2000 rows as a defensive safety net (never hit in practice). Server - /api/issues/:id/timeline now returns a flat ASC TimelineEntry[] (matches the legacy desktop contract — older Multica.app builds keep working because the wrapped TimelineResponse + cursors are gone, and the raw array shape was always what they consumed). - /api/issues/:id/comments drops limit/offset; only ?since is honoured for the CLI agent-polling flow. - Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest and the timelineCursor encoding. - Replace with ListCommentsForIssue / ListCommentsSinceForIssue / ListActivitiesForIssue (capped by argument). CLI - multica issue comment list drops --limit / --offset and the X-Total-Count reporting; --since is preserved for incremental polling. Frontend - Replace useInfiniteQuery with useQuery in useIssueTimeline; drop fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount. - Remove timeline-cache helpers (mapAllEntries / filterAllEntries / prependToLatestPage) and the TimelinePage / TimelinePageParam types. - WS event handlers update the single flat-array cache directly. - Drop the orphan-reply rescue in issue-detail — every reply's parent is now guaranteed to be in the same array. - Strip the "show older / show newer / jump to latest" buttons and their i18n strings. Co-authored-by: multica-agent <github@multica.ai> * fix(timeline): address review feedback on pagination removal Three issues caught in PR #2322 review: 1. /timeline broke for stale clients between #2128 and this PR. They send ?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema; the new flat-array response was failing schema validation and falling back to an empty timeline. Restore the wrapped shape on those query params (DESC entries, null cursors, has_more_*=false), keeping the flat ASC array for bare requests. Around-mode now also fills target_index from the merged slice so legacy clients can still scroll-to-anchor without a follow-up. 2. The agent prompts in runtime_config.go and prompt.go still told agents that `multica issue comment list` accepts --limit/--offset and to use `--limit 30` on truncated output. With those flags removed in this PR, new agent runs would hit "unknown flag" or skip context. Update the prompt copy to "returns all comments, capped at 2000; --since for incremental polling". 3. useCreateComment's onSuccess was a bare append to the timeline cache with no id-dedupe, so a fast comment:created WS event firing before onSuccess produced a transient duplicate. Restore the id guard the old prependToLatestPage helper used to provide. Adds two new boundary tests: - TestListTimeline_LegacyWrappedShape_OnPaginationParams - TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex Co-authored-by: multica-agent <github@multica.ai> * test(handler): fix timeline test assertions for handler-package isolation The TestListTimeline_* assertions assumed CreateIssue would seed an "issue_created" activity_log row, but the activity listener that publishes those rows is registered in cmd/server/main.go — handler-package tests don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the test expected ≥6. Drop the auto-activity assumption: assert exactly 5 entries in TestListTimeline_MergesCommentsAndActivities, and tighten TestListTimeline_EmptyIssue to assert a fully-empty timeline. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type {
|
|
IssueStatus,
|
|
ListIssuesParams,
|
|
ListIssuesCache,
|
|
} 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,
|
|
/** Full-issue timeline (single TanStack Query, no cursor). */
|
|
timeline: (issueId: string) =>
|
|
["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),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
|
* comments + activities for an issue (server caps at 2000 as a safety net).
|
|
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
|
|
* entries per issue) it added complexity without a UX win and broke reply
|
|
* threads at page boundaries.
|
|
*/
|
|
export function issueTimelineOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.timeline(issueId),
|
|
queryFn: () => api.listTimeline(issueId),
|
|
});
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|