diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 4bb0048f..089b236f 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -2,11 +2,11 @@ import * as React from "react"; import { useMemo, useState } from "react"; import * as RouterDom from "react-router-dom"; import type { Issue } from "@paperclipai/shared"; -import { useQuery } from "@tanstack/react-query"; -import { issuesApi } from "@/api/issues"; -import { queryKeys } from "@/lib/queryKeys"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { fetchIssueDetail, getCachedIssueDetail, prefetchIssueDetail } from "@/lib/issueDetailCache"; +import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { StatusIcon } from "@/components/StatusIcon"; @@ -67,47 +67,92 @@ export function IssueQuicklookCard({ export const IssueLinkQuicklook = React.forwardRef< HTMLAnchorElement, - React.ComponentProps & { issuePathId: string } + React.ComponentProps & { + issuePathId: string; + disableIssueQuicklook?: boolean; + issuePrefetch?: Issue | null; + } >(function IssueLinkQuicklookImpl( { issuePathId, to, children, className, + state, + disableIssueQuicklook = false, + issuePrefetch = null, onClick, + onClickCapture, + onMouseEnter, + onFocus, + onTouchStart, ...props }, ref, ) { + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state; + const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined); const { data, isLoading } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), - queryFn: () => issuesApi.get(issuePathId), + queryFn: () => fetchIssueDetail(queryClient, issuePathId), enabled: open, + initialData: () => cachedIssue, staleTime: 60_000, }); const detailPath = createIssueDetailPath(issuePathId); + const handlePrefetch = React.useCallback(() => { + void prefetchIssueDetail(queryClient, issuePathId, { issue: issuePrefetch }); + }, [issuePathId, issuePrefetch, queryClient]); + const link = ( + { + handlePrefetch(); + onMouseEnter?.(event); + }} + onFocus={(event) => { + handlePrefetch(); + onFocus?.(event); + }} + onTouchStart={(event) => { + handlePrefetch(); + onTouchStart?.(event); + }} + onClickCapture={(event) => { + handlePrefetch(); + onClickCapture?.(event); + }} + onClick={(event) => { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + ); + + if (disableIssueQuicklook) { + return link; + } return ( setOpen(true)} + onMouseEnter={() => { + handlePrefetch(); + setOpen(true); + }} onMouseLeave={() => setOpen(false)} > - { - setOpen(false); - onClick?.(event); - }} - {...props} - > - {children} - + {link} event.preventDefault()} > {data ? ( - + ) : (
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 2b52a042..60a201ac 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -7,10 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; vi.mock("@/lib/router", () => ({ - Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + Link: ({ + children, + className, + disableIssueQuicklook: _disableIssueQuicklook, + issuePrefetch, + ...props + }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => ( {children} @@ -157,6 +164,21 @@ describe("IssueRow", () => { }); }); + it("passes the visible row issue into the navigation prefetch path", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link?.getAttribute("data-issue-prefetch-id")).toBe("issue-1"); + + act(() => { + root.unmount(); + }); + }); + it("renders titleSuffix inline after the issue title", () => { const root = createRoot(container); const issue = createIssue({ title: "Parent task" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 2ba4e92e..3bd8a2ea 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -59,6 +59,7 @@ export function IssueRow({ to={createIssueDetailPath(issuePathId)} state={detailState} disableIssueQuicklook + issuePrefetch={issue} data-inbox-issue-link onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts new file mode 100644 index 00000000..a2fbe0e3 --- /dev/null +++ b/ui/src/lib/issueDetailCache.test.ts @@ -0,0 +1,116 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issuesApi } from "@/api/issues"; +import { + fetchIssueDetail, + getCachedIssueDetail, + prefetchIssueDetail, + seedIssueDetailCache, +} from "./issueDetailCache"; +import { queryKeys } from "./queryKeys"; + +vi.mock("@/api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fast link target", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("issueDetailCache", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + it("seeds and resolves issue detail by both identifier and id", () => { + const issue = createIssue(); + + seedIssueDetailCache(queryClient, issue, { issueRef: issue.identifier }); + + expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); + expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + }); + + it("prefetches with the provided issue snapshot before the network result lands", async () => { + const issue = createIssue(); + let releaseFetch: (() => void) | null = null; + vi.mocked(issuesApi.get).mockImplementation( + () => + new Promise((resolve) => { + releaseFetch = () => resolve(issue); + }), + ); + + const prefetchPromise = prefetchIssueDetail(queryClient, issue.identifier!, { issue }); + + expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); + expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); + + releaseFetch?.(); + await prefetchPromise; + }); + + it("hydrates both cache aliases from a fetched issue detail response", async () => { + const issue = createIssue(); + vi.mocked(issuesApi.get).mockResolvedValue(issue); + + const result = await fetchIssueDetail(queryClient, issue.identifier!); + + expect(result).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toEqual(issue); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + }); +}); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts new file mode 100644 index 00000000..b4fab727 --- /dev/null +++ b/ui/src/lib/issueDetailCache.ts @@ -0,0 +1,103 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; + +const ISSUE_DETAIL_QUERY_PREFIX = ["issues", "detail"] as const; +const ISSUE_DETAIL_STALE_TIME_MS = 60_000; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function collectIssueRefs( + issueRef: string | null | undefined, + issue?: Pick | null, +): string[] { + const refs = new Set(); + 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, refs: Iterable) { + const refSet = refs instanceof Set ? refs : new Set(refs); + return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier)); +} + +function mergeIssueSnapshots(existing: Issue | undefined, incoming: Issue): Issue { + if (!existing) return incoming; + return { + ...existing, + ...incoming, + }; +} + +export function getIssueDetailCacheRefs(issue: Pick): string[] { + return collectIssueRefs(null, issue); +} + +export function getCachedIssueDetail( + queryClient: QueryClient, + issueRef: string | null | undefined, + issue?: Pick | null, +): Issue | undefined { + const refs = collectIssueRefs(issueRef, issue); + + for (const ref of refs) { + const cached = queryClient.getQueryData(queryKeys.issues.detail(ref)); + if (cached) return cached; + } + + const cachedEntries = queryClient.getQueriesData({ queryKey: ISSUE_DETAIL_QUERY_PREFIX }); + return cachedEntries + .map(([, cachedIssue]) => cachedIssue) + .find((cachedIssue): cachedIssue is Issue => !!cachedIssue && matchesIssueRef(cachedIssue, refs)); +} + +export function seedIssueDetailCache( + queryClient: QueryClient, + issue: Issue, + options?: { + issueRef?: string | null; + }, +): Issue { + const refs = collectIssueRefs(options?.issueRef, issue); + const merged = mergeIssueSnapshots(getCachedIssueDetail(queryClient, options?.issueRef, issue), issue); + + for (const ref of refs) { + queryClient.setQueryData( + queryKeys.issues.detail(ref), + (existing) => mergeIssueSnapshots(existing, merged), + ); + } + + return merged; +} + +export async function fetchIssueDetail( + queryClient: QueryClient, + issueRef: string, +): Promise { + const issue = await issuesApi.get(issueRef); + return seedIssueDetailCache(queryClient, issue, { issueRef }); +} + +export function prefetchIssueDetail( + queryClient: QueryClient, + issueRef: string, + options?: { + issue?: Issue | null; + }, +) { + if (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, + }); +} diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 77fab046..c1660277 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import * as RouterDom from "react-router-dom"; import type { NavigateOptions, To } from "react-router-dom"; +import type { Issue } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook"; import { @@ -49,18 +50,28 @@ export * from "react-router-dom"; type CompanyLinkProps = React.ComponentProps & { disableIssueQuicklook?: boolean; + issuePrefetch?: Issue | null; }; export const Link = React.forwardRef( - function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) { + function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix(); const resolvedTo = resolveTo(to, companyPrefix); const issuePathId = disableIssueQuicklook - ? null + ? parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname) : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); if (issuePathId) { - return ; + return ( + + ); } return ; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3cb0efa8..6786e218 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -30,6 +30,7 @@ import { rememberIssueDetailLocationState, withIssueDetailHeaderSeed, } from "../lib/issueDetailBreadcrumb"; +import { prefetchIssueDetail } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget, @@ -1578,6 +1579,7 @@ export function Inbox() { const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue)); rememberIssueDetailLocationState(pathId, detailState); + void prefetchIssueDetail(queryClient, pathId, { issue }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { @@ -1586,6 +1588,7 @@ export function Inbox() { withIssueDetailHeaderSeed(issueLinkState, item.issue), ); rememberIssueDetailLocationState(pathId, detailState); + void prefetchIssueDetail(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 3fbc72f9..ceceea11 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -27,6 +27,7 @@ import { rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; +import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -392,6 +393,24 @@ export function IssueDetail() { const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + const resolvedIssueDetailState = useMemo( + () => readIssueDetailLocationState(issueId, location.state, location.search), + [issueId, location.state, location.search], + ); + const issueHeaderSeed = useMemo( + () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), + [location.state, resolvedIssueDetailState], + ); + const cachedIssue = useMemo( + () => + issueId + ? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? { + id: issueHeaderSeed.id, + identifier: issueHeaderSeed.identifier, + } : null) + : undefined, + [issueHeaderSeed, issueId, queryClient], + ); useEffect(() => { setIssueChatInitialTranscriptReady(false); @@ -399,8 +418,9 @@ export function IssueDetail() { const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), - queryFn: () => issuesApi.get(issueId!), + queryFn: () => fetchIssueDetail(queryClient, issueId!), enabled: !!issueId, + initialData: () => cachedIssue, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => { @@ -491,14 +511,6 @@ export function IssueDetail() { ), [activeRun, liveRuns], ); - const resolvedIssueDetailState = useMemo( - () => readIssueDetailLocationState(issueId, location.state, location.search), - [issueId, location.state, location.search], - ); - const issueHeaderSeed = useMemo( - () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), - [location.state, resolvedIssueDetailState], - ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -1639,6 +1651,7 @@ export function IssueDetail() { rememberIssueDetailLocationState( ancestor.identifier ?? ancestor.id, @@ -1883,6 +1896,7 @@ export function IssueDetail() { key={child.id} to={createIssueDetailPath(child.identifier ?? child.id)} state={resolvedIssueDetailState ?? location.state} + issuePrefetch={child} onClickCapture={() => rememberIssueDetailLocationState( child.identifier ?? child.id,