mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Merge pull request #3542 from cryppadotta/PAP-1346-faster-issue-to-issue-links
Speed up issue-to-issue navigation
This commit is contained in:
commit
d6b06788f6
10 changed files with 401 additions and 35 deletions
|
|
@ -2,11 +2,16 @@ import * as React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import * as RouterDom from "react-router-dom";
|
import * as RouterDom from "react-router-dom";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "@/api/issues";
|
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
|
||||||
import { timeAgo } from "@/lib/timeAgo";
|
import { timeAgo } from "@/lib/timeAgo";
|
||||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
|
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
|
||||||
|
import {
|
||||||
|
fetchIssueDetail,
|
||||||
|
getCachedIssueDetail,
|
||||||
|
ISSUE_DETAIL_STALE_TIME_MS,
|
||||||
|
prefetchIssueDetail,
|
||||||
|
} from "@/lib/issueDetailCache";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { StatusIcon } from "@/components/StatusIcon";
|
import { StatusIcon } from "@/components/StatusIcon";
|
||||||
|
|
@ -67,47 +72,92 @@ export function IssueQuicklookCard({
|
||||||
|
|
||||||
export const IssueLinkQuicklook = React.forwardRef<
|
export const IssueLinkQuicklook = React.forwardRef<
|
||||||
HTMLAnchorElement,
|
HTMLAnchorElement,
|
||||||
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
|
React.ComponentProps<typeof RouterDom.Link> & {
|
||||||
|
issuePathId: string;
|
||||||
|
disableIssueQuicklook?: boolean;
|
||||||
|
issuePrefetch?: Issue | null;
|
||||||
|
}
|
||||||
>(function IssueLinkQuicklookImpl(
|
>(function IssueLinkQuicklookImpl(
|
||||||
{
|
{
|
||||||
issuePathId,
|
issuePathId,
|
||||||
to,
|
to,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
state,
|
||||||
|
disableIssueQuicklook = false,
|
||||||
|
issuePrefetch = null,
|
||||||
onClick,
|
onClick,
|
||||||
|
onClickCapture,
|
||||||
|
onMouseEnter,
|
||||||
|
onFocus,
|
||||||
|
onTouchStart,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state;
|
||||||
|
const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined);
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKeys.issues.detail(issuePathId),
|
queryKey: queryKeys.issues.detail(issuePathId),
|
||||||
queryFn: () => issuesApi.get(issuePathId),
|
queryFn: () => fetchIssueDetail(queryClient, issuePathId),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
staleTime: 60_000,
|
initialData: () => cachedIssue,
|
||||||
|
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailPath = createIssueDetailPath(issuePathId);
|
const detailPath = createIssueDetailPath(issuePathId);
|
||||||
|
const handlePrefetch = React.useCallback(() => {
|
||||||
|
void prefetchIssueDetail(queryClient, issuePathId, { issue: issuePrefetch });
|
||||||
|
}, [issuePathId, issuePrefetch, queryClient]);
|
||||||
|
const link = (
|
||||||
|
<RouterDom.Link
|
||||||
|
ref={ref}
|
||||||
|
to={to}
|
||||||
|
state={prefetchedState}
|
||||||
|
className={className}
|
||||||
|
onMouseEnter={(event) => {
|
||||||
|
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}
|
||||||
|
</RouterDom.Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disableIssueQuicklook) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
asChild
|
asChild
|
||||||
onMouseEnter={() => setOpen(true)}
|
onMouseEnter={() => {
|
||||||
|
handlePrefetch();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
onMouseLeave={() => setOpen(false)}
|
onMouseLeave={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<RouterDom.Link
|
{link}
|
||||||
ref={ref}
|
|
||||||
to={to}
|
|
||||||
className={className}
|
|
||||||
onClick={(event) => {
|
|
||||||
setOpen(false);
|
|
||||||
onClick?.(event);
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</RouterDom.Link>
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-72 p-3"
|
className="w-72 p-3"
|
||||||
|
|
@ -118,7 +168,7 @@ export const IssueLinkQuicklook = React.forwardRef<
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{data ? (
|
{data ? (
|
||||||
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
|
<IssueQuicklookCard issue={data} linkTo={detailPath} linkState={prefetchedState} compact />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-4 w-24 rounded bg-accent/50" />
|
<div className="h-4 w-24 rounded bg-accent/50" />
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { IssueRow } from "./IssueRow";
|
import { IssueRow } from "./IssueRow";
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
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 }) => (
|
||||||
<a
|
<a
|
||||||
className={className}
|
className={className}
|
||||||
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
|
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
|
||||||
|
data-issue-prefetch-id={issuePrefetch?.id}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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(<IssueRow issue={createIssue()} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("renders titleSuffix inline after the issue title", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const issue = createIssue({ title: "Parent task" });
|
const issue = createIssue({ title: "Parent task" });
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export function IssueRow({
|
||||||
to={createIssueDetailPath(issuePathId)}
|
to={createIssueDetailPath(issuePathId)}
|
||||||
state={detailState}
|
state={detailState}
|
||||||
disableIssueQuicklook
|
disableIssueQuicklook
|
||||||
|
issuePrefetch={issue}
|
||||||
data-inbox-issue-link
|
data-inbox-issue-link
|
||||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
107
ui/src/lib/issueDetailCache.test.ts
Normal file
107
ui/src/lib/issueDetailCache.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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> = {}): 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 without forcing a fresh fetch", async () => {
|
||||||
|
const issue = createIssue();
|
||||||
|
|
||||||
|
await prefetchIssueDetail(queryClient, issue.identifier!, { issue });
|
||||||
|
|
||||||
|
expect(getCachedIssueDetail(queryClient, issue.identifier)).toEqual(issue);
|
||||||
|
expect(getCachedIssueDetail(queryClient, issue.id)).toEqual(issue);
|
||||||
|
expect(issuesApi.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
ui/src/lib/issueDetailCache.ts
Normal file
103
ui/src/lib/issueDetailCache.ts
Normal file
|
|
@ -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;
|
||||||
|
export 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<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 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 (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedEntries = queryClient.getQueriesData<Issue>({ 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<Issue>(
|
||||||
|
queryKeys.issues.detail(ref),
|
||||||
|
(existing) => mergeIssueSnapshots(existing, merged),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIssueDetail(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
issueRef: string,
|
||||||
|
): Promise<Issue> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,46 @@ describe("navigation-scroll", () => {
|
||||||
).toBe(false);
|
).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", () => {
|
it("does not reset scroll on the initial render or when the pathname is unchanged", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldResetScrollOnNavigation({
|
shouldResetScrollOnNavigation({
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export function shouldResetScrollOnNavigation(params: {
|
||||||
if (previousPathname === null) return false;
|
if (previousPathname === null) return false;
|
||||||
if (previousPathname === pathname) return false;
|
if (previousPathname === pathname) return false;
|
||||||
if (navigationType === "POP") return false;
|
if (navigationType === "POP") return false;
|
||||||
|
if (isIssueDetailPathChange(previousPathname, pathname)) return true;
|
||||||
return hasSidebarScrollResetState(state);
|
return hasSidebarScrollResetState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,3 +44,20 @@ function hasSidebarScrollResetState(state: unknown): boolean {
|
||||||
if (!state || typeof state !== "object") return false;
|
if (!state || typeof state !== "object") return false;
|
||||||
return (state as Record<string, unknown>).paperclipSidebarScrollReset === true;
|
return (state as Record<string, unknown>).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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as RouterDom from "react-router-dom";
|
import * as RouterDom from "react-router-dom";
|
||||||
import type { NavigateOptions, To } from "react-router-dom";
|
import type { NavigateOptions, To } from "react-router-dom";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { useCompany } from "@/context/CompanyContext";
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
||||||
import {
|
import {
|
||||||
|
|
@ -49,18 +50,26 @@ export * from "react-router-dom";
|
||||||
|
|
||||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||||
disableIssueQuicklook?: boolean;
|
disableIssueQuicklook?: boolean;
|
||||||
|
issuePrefetch?: Issue | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||||
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
|
function CompanyLink({ to, disableIssueQuicklook = false, issuePrefetch = null, ...props }, ref) {
|
||||||
const companyPrefix = useActiveCompanyPrefix();
|
const companyPrefix = useActiveCompanyPrefix();
|
||||||
const resolvedTo = resolveTo(to, companyPrefix);
|
const resolvedTo = resolveTo(to, companyPrefix);
|
||||||
const issuePathId = disableIssueQuicklook
|
const issuePathId = parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||||
? null
|
|
||||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
|
||||||
|
|
||||||
if (issuePathId) {
|
if (issuePathId) {
|
||||||
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
|
return (
|
||||||
|
<IssueLinkQuicklook
|
||||||
|
ref={ref}
|
||||||
|
to={resolvedTo}
|
||||||
|
issuePathId={issuePathId}
|
||||||
|
disableIssueQuicklook={disableIssueQuicklook}
|
||||||
|
issuePrefetch={issuePrefetch}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
rememberIssueDetailLocationState,
|
rememberIssueDetailLocationState,
|
||||||
withIssueDetailHeaderSeed,
|
withIssueDetailHeaderSeed,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
|
import { prefetchIssueDetail } from "../lib/issueDetailCache";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
isKeyboardShortcutTextInputTarget,
|
isKeyboardShortcutTextInputTarget,
|
||||||
|
|
@ -1578,6 +1579,7 @@ export function Inbox() {
|
||||||
const pathId = issue.identifier ?? issue.id;
|
const pathId = issue.identifier ?? issue.id;
|
||||||
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
|
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
|
||||||
rememberIssueDetailLocationState(pathId, detailState);
|
rememberIssueDetailLocationState(pathId, detailState);
|
||||||
|
void prefetchIssueDetail(queryClient, pathId, { issue });
|
||||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||||
} else if (item) {
|
} else if (item) {
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
|
|
@ -1586,6 +1588,7 @@ export function Inbox() {
|
||||||
withIssueDetailHeaderSeed(issueLinkState, item.issue),
|
withIssueDetailHeaderSeed(issueLinkState, item.issue),
|
||||||
);
|
);
|
||||||
rememberIssueDetailLocationState(pathId, detailState);
|
rememberIssueDetailLocationState(pathId, detailState);
|
||||||
|
void prefetchIssueDetail(queryClient, pathId, { issue: item.issue });
|
||||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item.kind === "approval") {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
act.navigate(`/approvals/${item.approval.id}`);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
rememberIssueDetailLocationState,
|
rememberIssueDetailLocationState,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
||||||
|
import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
resolveIssueDetailGoKeyAction,
|
resolveIssueDetailGoKeyAction,
|
||||||
|
|
@ -392,6 +393,24 @@ export function IssueDetail() {
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||||
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
|
const commentComposerRef = useRef<IssueChatComposerHandle | null>(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(() => {
|
useEffect(() => {
|
||||||
setIssueChatInitialTranscriptReady(false);
|
setIssueChatInitialTranscriptReady(false);
|
||||||
|
|
@ -399,8 +418,9 @@ export function IssueDetail() {
|
||||||
|
|
||||||
const { data: issue, isLoading, error } = useQuery({
|
const { data: issue, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.issues.detail(issueId!),
|
queryKey: queryKeys.issues.detail(issueId!),
|
||||||
queryFn: () => issuesApi.get(issueId!),
|
queryFn: () => fetchIssueDetail(queryClient, issueId!),
|
||||||
enabled: !!issueId,
|
enabled: !!issueId,
|
||||||
|
initialData: () => cachedIssue,
|
||||||
});
|
});
|
||||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||||
const commentComposerDisabledReason = useMemo(() => {
|
const commentComposerDisabledReason = useMemo(() => {
|
||||||
|
|
@ -491,14 +511,6 @@ export function IssueDetail() {
|
||||||
),
|
),
|
||||||
[activeRun, liveRuns],
|
[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(
|
const sourceBreadcrumb = useMemo(
|
||||||
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||||
[issueId, location.state, location.search],
|
[issueId, location.state, location.search],
|
||||||
|
|
@ -1883,6 +1895,7 @@ export function IssueDetail() {
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={createIssueDetailPath(child.identifier ?? child.id)}
|
to={createIssueDetailPath(child.identifier ?? child.id)}
|
||||||
state={resolvedIssueDetailState ?? location.state}
|
state={resolvedIssueDetailState ?? location.state}
|
||||||
|
issuePrefetch={child}
|
||||||
onClickCapture={() =>
|
onClickCapture={() =>
|
||||||
rememberIssueDetailLocationState(
|
rememberIssueDetailLocationState(
|
||||||
child.identifier ?? child.id,
|
child.identifier ?? child.id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue