forked from farhoodlabs/paperclip
Merge pull request #3542 from cryppadotta/PAP-1346-faster-issue-to-issue-links
Speed up issue-to-issue navigation
This commit is contained in:
@@ -2,11 +2,16 @@ 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,
|
||||
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";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
@@ -67,47 +72,92 @@ export function IssueQuicklookCard({
|
||||
|
||||
export const IssueLinkQuicklook = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
|
||||
React.ComponentProps<typeof RouterDom.Link> & {
|
||||
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,
|
||||
staleTime: 60_000,
|
||||
initialData: () => cachedIssue,
|
||||
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseEnter={() => {
|
||||
handlePrefetch();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<RouterDom.Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
setOpen(false);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RouterDom.Link>
|
||||
{link}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
@@ -118,7 +168,7 @@ export const IssueLinkQuicklook = React.forwardRef<
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{data ? (
|
||||
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
|
||||
<IssueQuicklookCard issue={data} linkTo={detailPath} linkState={prefetchedState} compact />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<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";
|
||||
|
||||
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
|
||||
className={className}
|
||||
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
|
||||
data-issue-prefetch-id={issuePrefetch?.id}
|
||||
{...props}
|
||||
>
|
||||
{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", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({ title: "Parent task" });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
+14
-5
@@ -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,26 @@ export * from "react-router-dom";
|
||||
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
issuePrefetch?: Issue | null;
|
||||
};
|
||||
|
||||
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 resolvedTo = resolveTo(to, companyPrefix);
|
||||
const issuePathId = disableIssueQuicklook
|
||||
? null
|
||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
const issuePathId = parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | 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(() => {
|
||||
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],
|
||||
@@ -1883,6 +1895,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,
|
||||
|
||||
Reference in New Issue
Block a user