From 1729e41179152a8077e675c38a1fc822b06f8331 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 11:05:32 -0500 Subject: [PATCH 1/4] Speed up issue-to-issue navigation --- ui/src/components/IssueLinkQuicklook.tsx | 83 ++++++++++++---- ui/src/components/IssueRow.test.tsx | 24 ++++- ui/src/components/IssueRow.tsx | 1 + ui/src/lib/issueDetailCache.test.ts | 116 +++++++++++++++++++++++ ui/src/lib/issueDetailCache.ts | 103 ++++++++++++++++++++ ui/src/lib/router.tsx | 17 +++- ui/src/pages/Inbox.tsx | 3 + ui/src/pages/IssueDetail.tsx | 32 +++++-- 8 files changed, 347 insertions(+), 32 deletions(-) create mode 100644 ui/src/lib/issueDetailCache.test.ts create mode 100644 ui/src/lib/issueDetailCache.ts 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, From e59047187b203cdc84a4d681fbeba605f6fc7869 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:43:51 -0500 Subject: [PATCH 2/4] Reset scroll on issue detail navigation --- ui/src/lib/navigation-scroll.test.ts | 40 ++++++++++++++++++++++++++++ ui/src/lib/navigation-scroll.ts | 18 +++++++++++++ 2 files changed, 58 insertions(+) diff --git a/ui/src/lib/navigation-scroll.test.ts b/ui/src/lib/navigation-scroll.test.ts index 636c29cf..967dfa19 100644 --- a/ui/src/lib/navigation-scroll.test.ts +++ b/ui/src/lib/navigation-scroll.test.ts @@ -39,6 +39,46 @@ describe("navigation-scroll", () => { ).toBe(false); }); + it("resets scroll when navigating directly between issue detail routes", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/issues/PAP-1346", + navigationType: "PUSH", + state: null, + }), + ).toBe(true); + + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/PAP/issues/PAP-1389", + pathname: "/PAP/issues/PAP-1346", + navigationType: "REPLACE", + state: null, + }), + ).toBe(true); + }); + + it("does not treat non-detail issue routes as issue-to-issue navigation", () => { + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/projects/project-1/issues/all", + pathname: "/issues/PAP-1346", + navigationType: "PUSH", + state: null, + }), + ).toBe(false); + + expect( + shouldResetScrollOnNavigation({ + previousPathname: "/issues/PAP-1389", + pathname: "/projects/project-1/issues/all", + navigationType: "PUSH", + state: null, + }), + ).toBe(false); + }); + it("does not reset scroll on the initial render or when the pathname is unchanged", () => { expect( shouldResetScrollOnNavigation({ diff --git a/ui/src/lib/navigation-scroll.ts b/ui/src/lib/navigation-scroll.ts index a4c5de14..6ea8a1bb 100644 --- a/ui/src/lib/navigation-scroll.ts +++ b/ui/src/lib/navigation-scroll.ts @@ -14,6 +14,7 @@ export function shouldResetScrollOnNavigation(params: { if (previousPathname === null) return false; if (previousPathname === pathname) return false; if (navigationType === "POP") return false; + if (isIssueDetailPathChange(previousPathname, pathname)) return true; return hasSidebarScrollResetState(state); } @@ -43,3 +44,20 @@ function hasSidebarScrollResetState(state: unknown): boolean { if (!state || typeof state !== "object") return false; return (state as Record).paperclipSidebarScrollReset === true; } + +function isIssueDetailPathChange(previousPathname: string, pathname: string): boolean { + const previousIssueRef = readIssueDetailPathRef(previousPathname); + const nextIssueRef = readIssueDetailPathRef(pathname); + return previousIssueRef !== null && nextIssueRef !== null && previousIssueRef !== nextIssueRef; +} + +function readIssueDetailPathRef(pathname: string): string | null { + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 2 && segments[0] === "issues") { + return segments[1] ?? null; + } + if (segments.length === 3 && segments[1] === "issues") { + return segments[2] ?? null; + } + return null; +} From 0cb42f49eafe95c78683e385e70acdfbba4ab7a4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 21:18:57 -0500 Subject: [PATCH 3/4] Fix rebased issue detail prefetch typing --- ui/src/lib/issueDetailCache.test.ts | 15 +++------------ ui/src/pages/IssueDetail.tsx | 1 - 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index a2fbe0e3..dd55ba72 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -84,23 +84,14 @@ describe("issueDetailCache", () => { expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); }); - it("prefetches with the provided issue snapshot before the network result lands", async () => { + it("prefetches with the provided issue snapshot without forcing a fresh fetch", 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 }); + await prefetchIssueDetail(queryClient, issue.identifier!, { issue }); expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue); expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue); - - releaseFetch?.(); - await prefetchPromise; + expect(issuesApi.get).not.toHaveBeenCalled(); }); it("hydrates both cache aliases from a fetched issue detail response", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ceceea11..982d7ace 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1651,7 +1651,6 @@ export function IssueDetail() { rememberIssueDetailLocationState( ancestor.identifier ?? ancestor.id, From 6844226572161a4ea267bfb026b509b88cc66dd5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 21:30:50 -0500 Subject: [PATCH 4/4] Address Greptile navigation review --- ui/src/components/IssueLinkQuicklook.tsx | 9 +++++++-- ui/src/lib/issueDetailCache.ts | 2 +- ui/src/lib/router.tsx | 4 +--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 089b236f..b6311eff 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -5,7 +5,12 @@ import type { Issue } from "@paperclipai/shared"; 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 { + fetchIssueDetail, + getCachedIssueDetail, + ISSUE_DETAIL_STALE_TIME_MS, + prefetchIssueDetail, +} from "@/lib/issueDetailCache"; import { queryKeys } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -99,7 +104,7 @@ export const IssueLinkQuicklook = React.forwardRef< queryFn: () => fetchIssueDetail(queryClient, issuePathId), enabled: open, initialData: () => cachedIssue, - staleTime: 60_000, + staleTime: ISSUE_DETAIL_STALE_TIME_MS, }); const detailPath = createIssueDetailPath(issuePathId); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index b4fab727..8e02e46e 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -4,7 +4,7 @@ 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; +export const ISSUE_DETAIL_STALE_TIME_MS = 60_000; function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index c1660277..c8d3a352 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -57,9 +57,7 @@ export const Link = React.forwardRef( function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) { const companyPrefix = useActiveCompanyPrefix(); const resolvedTo = resolveTo(to, companyPrefix); - const issuePathId = disableIssueQuicklook - ? parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname) - : parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); + const issuePathId = parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname); if (issuePathId) { return (