mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## 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>
197 lines
6.9 KiB
TypeScript
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),
|
|
]);
|
|
}
|