perf(ui): warm issue detail navigation (#10416)
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent companies > - The board UI opens issue details from lists, quicklooks, and the inbox > - Those navigation paths already have enough issue data to paint the header immediately, but the comment feed still waits for its first request > - That delay makes repeat navigation feel cold and can briefly show an empty thread before comments arrive > - This pull request centralizes the comment page shape, prefetches issue detail plus the first comment page, and renders a reserved skeleton while an uncached page is loading > - The benefit is faster, stable warm navigation without coupling this change to the separate aggregate issue-detail API work ## Linked Issues or Issue Description - **Subsystem affected:** `ui/` — React + Vite board UI - **Problem or motivation:** Opening an issue from an already-loaded list still waits for the first comments request and may flash the empty-thread state, making warm navigation feel slower than necessary. - **Proposed solution:** Prefetch the issue-detail snapshot and first comments page from every issue navigation entry point, reuse one page-size constant for prefetch and render queries, and show the existing chat skeleton until the uncached initial page resolves. - **Alternatives considered:** Relying only on detail-query prefetch leaves comments cold; bundling this with the aggregate issue-detail endpoint would make the UI improvement harder to review and land independently. - **Roadmap alignment:** `ROADMAP.md` has no overlapping issue-navigation initiative. This is a focused board responsiveness improvement. - **Related pull requests:** #10409 establishes the issue-detail performance baseline; #10414 reduces server-side issue-detail request overhead. ## What Changed - Added one shared issue-comment page-size constant used by rendering and prefetching. - Added first-page comment prefetching and a combined navigation prefetch helper. - Wired quicklook, issue-list keyboard navigation, and inbox navigation to warm both caches. - Kept the existing chat skeleton visible while the first uncached comment page loads. - Added focused cache behavior tests for comment and combined navigation prefetching. ## Verification - `vitest run ui/src/lib/prefetchIssueComments.test.ts` — 3 tests passed. - `tsc -b ui` — passed. - `pnpm check:token-gates` — passed. ## Risks - Low risk: this adds background prefetch requests on intentional issue navigation/hover paths. React Query stale-time deduplication prevents repeat requests while the cache is fresh. - The change intentionally remains independent of the separate aggregate `getView` work and composes with it through the same query keys. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent, exact model `gpt-5.6-sol`; context-window size was not exposed; reasoning mode with repository tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
170c1e5adb
commit
1452d5f413
|
|
@ -8,7 +8,7 @@ import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDet
|
|||
import {
|
||||
getIssueDetailQueryOptions,
|
||||
ISSUE_DETAIL_STALE_TIME_MS,
|
||||
prefetchIssueDetail,
|
||||
prefetchIssueDetailForNavigation,
|
||||
} from "@/lib/issueDetailCache";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -209,7 +209,7 @@ export const IssueLinkQuicklook = React.forwardRef<
|
|||
|
||||
const detailPath = createIssueDetailPath(issuePathId);
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchIssueDetail(queryClient, issuePathId, { issue: issuePrefetch });
|
||||
void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue: issuePrefetch });
|
||||
}, [issuePathId, issuePrefetch, queryClient]);
|
||||
const link = (
|
||||
<RouterDom.Link
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVisibilityRefetchInterval } from "@/lib/polling";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { createIssueDetailPath, rememberIssueDetailLocationState, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import { prefetchIssueDetailForNavigation } from "../lib/issueDetailCache";
|
||||
import {
|
||||
buildSubIssueProgressSummary,
|
||||
shouldRenderSubIssueProgressSummary,
|
||||
|
|
@ -653,6 +654,7 @@ export function IssuesList({
|
|||
}: IssuesListProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { keyboardShortcutsEnabled } = useGeneralSettings();
|
||||
// Keyboard selection for the list view (mirrors the inbox). Hover moves the
|
||||
// selection only after real pointer movement, so keyboard-driven scrolling
|
||||
|
|
@ -1407,6 +1409,11 @@ export function IssuesList({
|
|||
const pathId = issue.identifier ?? issue.id;
|
||||
const detailState = withIssueDetailHeaderSeed(st.issueLinkState, issue);
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
// Seed the full list-row snapshot + first comments page before we
|
||||
// navigate so keyboard-driven opens paint from cache instantly, the
|
||||
// same way pointer hover/click does through the issue link. Mirrors
|
||||
// the inbox Enter handler.
|
||||
void prefetchIssueDetailForNavigation(queryClient, pathId, { issue });
|
||||
navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
break;
|
||||
}
|
||||
|
|
@ -1416,7 +1423,7 @@ export function IssuesList({
|
|||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [keyboardShortcutsEnabled, navigate]);
|
||||
}, [keyboardShortcutsEnabled, navigate, queryClient]);
|
||||
|
||||
// Keep the keyboard selection visible while navigating. Depends on the
|
||||
// render budget too: a selection past the mounted batch scrolls once its
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { getNextIssueCommentPageParam, ISSUE_COMMENT_PAGE_SIZE } from "@/lib/optimistic-issue-comments";
|
||||
|
||||
const ISSUE_DETAIL_QUERY_PREFIX = ["issues", "detail"] as const;
|
||||
export const ISSUE_DETAIL_STALE_TIME_MS = 60_000;
|
||||
/**
|
||||
* Freshness window for a prefetched first comments page. Matches the global
|
||||
* query staleTime so a warm navigation that arrives within the window renders
|
||||
* the seeded comments without an immediate refetch (no loading state), while a
|
||||
* later revisit still revalidates in the background.
|
||||
*/
|
||||
export const ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS = 30_000;
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
|
|
@ -144,3 +152,45 @@ export function prefetchIssueDetail(
|
|||
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the first page of the issue-detail comment feed under the exact infinite
|
||||
* query key IssueDetail mounts, so a subsequent navigation paints comments from
|
||||
* cache instead of waiting on a fetch. Keyed by issue ref and always background
|
||||
* revalidated by the mounted query, so it never surfaces stale cross-issue data.
|
||||
*/
|
||||
export function prefetchIssueComments(queryClient: QueryClient, issueRef: string) {
|
||||
return queryClient.prefetchInfiniteQuery({
|
||||
queryKey: queryKeys.issues.comments(issueRef),
|
||||
queryFn: ({ pageParam }: { pageParam: string | null }) =>
|
||||
issuesApi.listComments(issueRef, {
|
||||
order: "desc",
|
||||
limit: ISSUE_COMMENT_PAGE_SIZE,
|
||||
...(pageParam ? { after: pageParam } : {}),
|
||||
}),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage: IssueComment[]) =>
|
||||
getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE),
|
||||
staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS,
|
||||
pages: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch everything the issue-detail first paint needs — the detail snapshot
|
||||
* and the first comments page — for instant warm navigation from a list row.
|
||||
* Seeds the full list-row snapshot when provided so the header + description
|
||||
* paint immediately with no loading state.
|
||||
*/
|
||||
export function prefetchIssueDetailForNavigation(
|
||||
queryClient: QueryClient,
|
||||
issueRef: string,
|
||||
options?: {
|
||||
issue?: Issue | null;
|
||||
},
|
||||
) {
|
||||
return Promise.all([
|
||||
prefetchIssueDetail(queryClient, issueRef, options),
|
||||
prefetchIssueComments(queryClient, issueRef),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* First-page size for the issue-detail comment feed. Single source of truth so
|
||||
* the render path (IssueDetail's `useInfiniteQuery`) and the navigation prefetch
|
||||
* (`prefetchIssueComments`) request identically-shaped pages — a mismatch would
|
||||
* leave the prefetched cache entry unable to satisfy the mounted query without a
|
||||
* refetch, defeating warm-navigation instant paint.
|
||||
*/
|
||||
export const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
|
||||
export interface IssueCommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { prefetchIssueComments, prefetchIssueDetailForNavigation } from "./issueDetailCache";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
vi.mock("@/api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createComment(overrides: Partial<IssueComment> = {}): IssueComment {
|
||||
return {
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
body: "hello",
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as IssueComment;
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
title: "Fast link target",
|
||||
description: "A complete snapshot.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
executionRunId: null,
|
||||
issueNumber: 1,
|
||||
requestDepth: 0,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
describe("prefetchIssueComments", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("warms the first comments page under the infinite query key IssueDetail mounts", async () => {
|
||||
const firstPage = [createComment({ id: "c1" }), createComment({ id: "c2" })];
|
||||
vi.mocked(issuesApi.listComments).mockResolvedValue(firstPage);
|
||||
|
||||
await prefetchIssueComments(queryClient, "PAP-1");
|
||||
|
||||
expect(issuesApi.listComments).toHaveBeenCalledWith("PAP-1", { order: "desc", limit: 50 });
|
||||
const cached = queryClient.getQueryData<{ pages: IssueComment[][]; pageParams: unknown[] }>(
|
||||
queryKeys.issues.comments("PAP-1"),
|
||||
);
|
||||
expect(cached?.pages).toEqual([firstPage]);
|
||||
expect(cached?.pageParams).toEqual([null]);
|
||||
});
|
||||
|
||||
it("does not refetch comments that are already fresh in cache", async () => {
|
||||
const firstPage = [createComment({ id: "c1" })];
|
||||
vi.mocked(issuesApi.listComments).mockResolvedValue(firstPage);
|
||||
|
||||
await prefetchIssueComments(queryClient, "PAP-1");
|
||||
await prefetchIssueComments(queryClient, "PAP-1");
|
||||
|
||||
// Second prefetch sees fresh data within staleTime and is a no-op.
|
||||
expect(issuesApi.listComments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("seeds both the detail snapshot and the first comments page for navigation", async () => {
|
||||
const issue = createIssue();
|
||||
const firstPage = [createComment({ id: "c1" })];
|
||||
vi.mocked(issuesApi.listComments).mockResolvedValue(firstPage);
|
||||
|
||||
await prefetchIssueDetailForNavigation(queryClient, issue.identifier!, { issue });
|
||||
|
||||
// Complete snapshot seeds the detail cache without a network fetch.
|
||||
expect(issuesApi.get).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue);
|
||||
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue);
|
||||
|
||||
const cachedComments = queryClient.getQueryData<{ pages: IssueComment[][] }>(
|
||||
queryKeys.issues.comments(issue.identifier!),
|
||||
);
|
||||
expect(cachedComments?.pages).toEqual([firstPage]);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,7 +42,7 @@ import {
|
|||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { prefetchIssueDetail } from "../lib/issueDetailCache";
|
||||
import { prefetchIssueDetailForNavigation } from "../lib/issueDetailCache";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
|
|
@ -2090,7 +2090,7 @@ export function Inbox() {
|
|||
const pathId = issue.identifier ?? issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
void prefetchIssueDetail(queryClient, pathId, { issue });
|
||||
void prefetchIssueDetailForNavigation(queryClient, pathId, { issue });
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") {
|
||||
|
|
@ -2099,7 +2099,7 @@ export function Inbox() {
|
|||
withIssueDetailHeaderSeed(issueLinkState, item.issue),
|
||||
);
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
void prefetchIssueDetail(queryClient, pathId, { issue: item.issue });
|
||||
void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue });
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item.kind === "approval") {
|
||||
act.navigate(`/approvals/${item.approval.id}`);
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import {
|
|||
createOptimisticIssueComment,
|
||||
flattenIssueCommentPages,
|
||||
getNextIssueCommentPageParam,
|
||||
ISSUE_COMMENT_PAGE_SIZE,
|
||||
isQueuedIssueComment,
|
||||
loadRemainingIssueCommentPages,
|
||||
matchesIssueRef,
|
||||
|
|
@ -249,7 +250,6 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
||||
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
|
||||
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
|
|
@ -884,6 +884,7 @@ type IssueDetailChatTabProps = {
|
|||
title?: string | null;
|
||||
} | null;
|
||||
comments: IssueDetailComment[];
|
||||
commentsInitialLoading?: boolean;
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
interactions: IssueThreadInteraction[];
|
||||
hasOlderComments: boolean;
|
||||
|
|
@ -973,6 +974,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
canFalsePositiveRecoveryAction,
|
||||
legacyRecoverySourceIssue,
|
||||
comments,
|
||||
commentsInitialLoading = false,
|
||||
locallyQueuedCommentRunIds,
|
||||
interactions,
|
||||
hasOlderComments,
|
||||
|
|
@ -1172,6 +1174,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? (
|
||||
<IssueChatSkeleton />
|
||||
) : (
|
||||
<ThreadComponent
|
||||
composerRef={composerRef}
|
||||
composerAccessory={composerAccessory}
|
||||
|
|
@ -1254,6 +1259,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
externalReferences={externalReferences}
|
||||
linkCaseReferences={linkCaseReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -4769,6 +4775,7 @@ export function IssueDetail() {
|
|||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||
comments={threadComments}
|
||||
commentsInitialLoading={commentsLoading}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
interactions={interactions}
|
||||
hasOlderComments={hasOlderComments}
|
||||
|
|
|
|||
Loading…
Reference in New Issue