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:
Dotta 2026-07-29 13:37:22 -05:00 committed by GitHub
parent 170c1e5adb
commit 1452d5f413
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 182 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -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),
]);
}

View File

@ -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;

View File

@ -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]);
});
});

View File

@ -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}`);

View File

@ -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}