Files
PaperClipAI/ui/src/lib/issueDetailCache.ts
T
DottaandPaperclip 1452d5f413 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>
2026-07-29 13:37:22 -05:00

197 lines
6.9 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
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;
}
function collectIssueRefs(
issueRef: string | null | undefined,
issue?: Pick<Issue, "id" | "identifier"> | null,
): string[] {
const refs = new Set<string>();
if (isNonEmptyString(issueRef)) refs.add(issueRef);
if (isNonEmptyString(issue?.id)) refs.add(issue.id);
if (isNonEmptyString(issue?.identifier)) refs.add(issue.identifier);
return Array.from(refs);
}
function matchesIssueRef(issue: Pick<Issue, "id" | "identifier">, refs: Iterable<string>) {
const refSet = refs instanceof Set ? refs : new Set(refs);
return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier));
}
function isCompleteIssueSnapshot(value: unknown): value is Issue {
if (typeof value !== "object" || value === null) return false;
const issue = value as Partial<Issue>;
return (
isNonEmptyString(issue.id)
&& isNonEmptyString(issue.companyId)
&& typeof issue.title === "string"
&& typeof issue.status === "string"
&& typeof issue.workMode === "string"
&& typeof issue.priority === "string"
&& (issue.projectId === null || typeof issue.projectId === "string")
&& (issue.parentId === null || typeof issue.parentId === "string")
&& (issue.identifier === null || typeof issue.identifier === "string")
&& (issue.description === null || typeof issue.description === "string")
&& (issue.assigneeAgentId === null || typeof issue.assigneeAgentId === "string")
&& (issue.assigneeUserId === null || typeof issue.assigneeUserId === "string")
&& (issue.executionRunId === null || typeof issue.executionRunId === "string")
&& (issue.issueNumber === null || typeof issue.issueNumber === "number")
&& typeof issue.requestDepth === "number"
&& issue.createdAt != null
&& issue.updatedAt != null
);
}
function mergeIssueSnapshots(existing: Issue | undefined, incoming: Issue): Issue {
if (!existing) return incoming;
return {
...existing,
...incoming,
};
}
export function getIssueDetailCacheRefs(issue: Pick<Issue, "id" | "identifier">): string[] {
return collectIssueRefs(null, issue);
}
export function getCachedIssueDetail(
queryClient: QueryClient,
issueRef: string | null | undefined,
issue?: Pick<Issue, "id" | "identifier"> | null,
): Issue | undefined {
const refs = collectIssueRefs(issueRef, issue);
for (const ref of refs) {
const cached = queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref));
if (isCompleteIssueSnapshot(cached)) return cached;
}
const cachedEntries = queryClient.getQueriesData<Issue>({ queryKey: ISSUE_DETAIL_QUERY_PREFIX });
return cachedEntries
.map(([, cachedIssue]) => cachedIssue)
.find((cachedIssue): cachedIssue is Issue =>
isCompleteIssueSnapshot(cachedIssue) && matchesIssueRef(cachedIssue, refs)
);
}
export function seedIssueDetailCache(
queryClient: QueryClient,
issue: Issue,
options?: {
issueRef?: string | null;
},
): Issue {
if (!isCompleteIssueSnapshot(issue)) return issue;
const refs = collectIssueRefs(options?.issueRef, issue);
const merged = mergeIssueSnapshots(getCachedIssueDetail(queryClient, options?.issueRef, issue), issue);
for (const ref of refs) {
queryClient.setQueryData<Issue>(
queryKeys.issues.detail(ref),
(existing) => mergeIssueSnapshots(existing, merged),
);
}
return merged;
}
export async function fetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
options?: { signal?: AbortSignal },
): Promise<Issue> {
const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef);
return seedIssueDetailCache(queryClient, issue, { issueRef });
}
export function getIssueDetailQueryOptions(
queryClient: QueryClient,
issueRef: string,
options?: {
placeholderIssue?: Pick<Issue, "id" | "identifier"> | null;
},
) {
return {
queryKey: queryKeys.issues.detail(issueRef),
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchIssueDetail(queryClient, issueRef, { signal }),
placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined),
};
}
export function prefetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
options?: {
issue?: Issue | null;
},
) {
if (isCompleteIssueSnapshot(options?.issue)) {
seedIssueDetailCache(queryClient, options.issue, { issueRef });
}
return queryClient.prefetchQuery({
queryKey: queryKeys.issues.detail(issueRef),
queryFn: () => fetchIssueDetail(queryClient, issueRef),
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),
]);
}