diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 27ba64e062..2320f5196e 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -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 = ( (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 diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 482fee82c5..886dc3df80 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -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), + ]); +} diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 83dfcd3219..f5b4814e1a 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -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; diff --git a/ui/src/lib/prefetchIssueComments.test.ts b/ui/src/lib/prefetchIssueComments.test.ts new file mode 100644 index 0000000000..4aeebd558c --- /dev/null +++ b/ui/src/lib/prefetchIssueComments.test.ts @@ -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 { + 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 { + 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]); + }); +}); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 654e41ce37..b50aace18d 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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}`); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 1533bd8792..c8b5f961cc 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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 = { @@ -884,6 +884,7 @@ type IssueDetailChatTabProps = { title?: string | null; } | null; comments: IssueDetailComment[]; + commentsInitialLoading?: boolean; locallyQueuedCommentRunIds: ReadonlyMap; 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({ ) : null} + {commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? ( + + ) : ( + )} ); }); @@ -4769,6 +4775,7 @@ export function IssueDetail() { canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction} legacyRecoverySourceIssue={legacyRecoverySourceIssue} comments={threadComments} + commentsInitialLoading={commentsLoading} locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} interactions={interactions} hasOlderComments={hasOlderComments}