diff --git a/ui/src/components/IssueLinkQuicklook.test.tsx b/ui/src/components/IssueLinkQuicklook.test.tsx index bb298a1df43..b2fa1788df0 100644 --- a/ui/src/components/IssueLinkQuicklook.test.tsx +++ b/ui/src/components/IssueLinkQuicklook.test.tsx @@ -6,9 +6,22 @@ import { MemoryRouter } from "react-router-dom"; import type { Issue } from "@paperclipai/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { IssueLinkQuicklook, QUICKLOOK_CONTENT_CLASS, quicklookAlignOffset } from "./IssueLinkQuicklook"; +import { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS, IssueLinkQuicklook, QUICKLOOK_CONTENT_CLASS, quicklookAlignOffset } from "./IssueLinkQuicklook"; const mockIssuesApiGet = vi.hoisted(() => vi.fn()); +const navigationPrefetches = vi.hoisted(() => [] as Array<{ ref: string; history: boolean }>); + +vi.mock("@/lib/issueDetailCache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + prefetchIssueDetailForNavigation: (...args: Parameters) => { + navigationPrefetches.push({ ref: args[1], history: args[2]?.history === true }); + // The other quicklook tests rely on the real prefetch seeding the issue. + return actual.prefetchIssueDetailForNavigation(args[0], args[1], { ...args[2], history: false }); + }, + }; +}); vi.mock("@/api/issues", () => ({ issuesApi: { @@ -95,6 +108,83 @@ describe("IssueLinkQuicklook", () => { vi.clearAllMocks(); }); + describe("navigation prefetch", () => { + function renderLink({ quicklook = false } = {}) { + navigationPrefetches.length = 0; + act(() => { + root.render( + + + + PAP-1 + + + , + ); + }); + return container.querySelector("a") as HTMLAnchorElement; + } + const hover = (link: HTMLAnchorElement, type: "mouseover" | "mouseout") => + act(() => { link.dispatchEvent(new MouseEvent(type, { bubbles: true, relatedTarget: document.body })); }); + + it("warms only the issue and recent comments when a pointer passes over a task", () => { + const link = renderLink(); + hover(link, "mouseover"); + act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS - 1); }); + hover(link, "mouseout"); + act(() => { vi.advanceTimersByTime(1_000); }); + expect(navigationPrefetches).toEqual([{ ref: "PAP-1", history: false }]); + }); + + it("waits for a hover to rest before warming history when the quicklook is on", () => { + const link = renderLink({ quicklook: true }); + hover(link, "mouseover"); + act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS - 1); }); + hover(link, "mouseout"); + act(() => { vi.advanceTimersByTime(1_000); }); + expect(navigationPrefetches).toEqual([{ ref: "PAP-1", history: false }]); + }); + + it("warms the full chat history once a hover rests on the task", () => { + const link = renderLink(); + hover(link, "mouseover"); + act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); }); + expect(navigationPrefetches).toEqual([{ ref: "PAP-1", history: false }, { ref: "PAP-1", history: true }]); + }); + + const press = (link: HTMLAnchorElement, pointerType: string) => + act(() => { link.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, pointerType })); }); + + it("warms the full chat history at once when the task is pressed with a mouse", () => { + const link = renderLink(); + press(link, "mouse"); + expect(navigationPrefetches).toEqual([{ ref: "PAP-1", history: true }]); + }); + + it("keeps a touch that may start a scroll to the light set until the click", () => { + const link = renderLink(); + press(link, "touch"); + act(() => { link.dispatchEvent(new TouchEvent("touchstart", { bubbles: true })); }); + act(() => { vi.advanceTimersByTime(1_000); }); + expect(navigationPrefetches.every((prefetch) => !prefetch.history)).toBe(true); + expect(navigationPrefetches.length).toBeGreaterThan(0); + act(() => { link.click(); }); + expect(navigationPrefetches.at(-1)).toEqual({ ref: "PAP-1", history: true }); + }); + + it("warms history for keyboard focus only when the focus rests on the task", () => { + const link = renderLink(); + act(() => { link.focus(); }); + act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS - 1); }); + act(() => { link.blur(); }); + act(() => { vi.advanceTimersByTime(1_000); }); + expect(navigationPrefetches).toEqual([{ ref: "PAP-1", history: false }]); + act(() => { link.focus(); }); + act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); }); + expect(navigationPrefetches.at(-1)).toEqual({ ref: "PAP-1", history: true }); + }); + }); + it("keeps portaled quicklook links mounted until after blur click handling", () => { const issue = createIssue(); diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index dc0fcbf02e4..ca87c4f498c 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -219,6 +219,9 @@ export function IssueQuicklookCard({ ); } +/** How long a hover rests on a task link before its full chat history is warmed. */ +export const ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS = 120; + export const IssueLinkQuicklook = React.forwardRef< HTMLAnchorElement, React.ComponentProps & { @@ -242,6 +245,8 @@ export const IssueLinkQuicklook = React.forwardRef< onClick, onClickCapture, onMouseEnter, + onMouseLeave, + onPointerDown, onFocus, onBlur, onTouchStart, @@ -361,9 +366,26 @@ export const IssueLinkQuicklook = React.forwardRef< }); const detailPath = createIssueDetailPath(issuePathId); - const handlePrefetch = React.useCallback(() => { - void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue: issuePrefetch }); - }, [issuePathId, issuePrefetch, queryClient]); + // A pointer, a touch or keyboard focus that passes over a row warms only the + // issue and its recent comments. A hover or focus that rests on the link, a + // mouse or pen press, or a click also warms the rest of the chat's first + // frame, so the open renders from cache. A touch can start a scroll, so it + // warms the full set only on the click. + const historyPrefetchTimerRef = React.useRef | null>(null); + const cancelHistoryPrefetch = React.useCallback(() => { + if (historyPrefetchTimerRef.current) clearTimeout(historyPrefetchTimerRef.current); + historyPrefetchTimerRef.current = null; + }, []); + React.useEffect(() => cancelHistoryPrefetch, [cancelHistoryPrefetch]); + const handlePrefetch = React.useCallback((history = true) => { + if (history) cancelHistoryPrefetch(); + void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue: issuePrefetch, history }); + }, [cancelHistoryPrefetch, issuePathId, issuePrefetch, queryClient]); + const handleDwellPrefetch = React.useCallback(() => { + handlePrefetch(false); + cancelHistoryPrefetch(); + historyPrefetchTimerRef.current = setTimeout(() => handlePrefetch(true), ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); + }, [cancelHistoryPrefetch, handlePrefetch]); const link = ( { - handlePrefetch(); + handleDwellPrefetch(); onMouseEnter?.(event); }} + onMouseLeave={(event) => { + cancelHistoryPrefetch(); + onMouseLeave?.(event); + }} + onPointerDown={(event) => { + handlePrefetch(event.pointerType !== "touch" && event.button === 0); + onPointerDown?.(event); + }} onFocus={(event) => { - handlePrefetch(); + handleDwellPrefetch(); openNow(); onFocus?.(event); }} onBlur={(event) => { + cancelHistoryPrefetch(); // Let clicks inside the portaled quicklook content finish before closing. setTimeout(() => close(), 0); onBlur?.(event); }} onTouchStart={(event) => { - handlePrefetch(); + handlePrefetch(false); onTouchStart?.(event); }} onClickCapture={(event) => { @@ -410,10 +441,8 @@ export const IssueLinkQuicklook = React.forwardRef< (next ? openNow() : close())}> { - handlePrefetch(); - scheduleOpen(); - }} + // The link's own hover handler warms the task; history waits for a dwell. + onMouseEnter={scheduleOpen} onMouseLeave={close} > {link} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index e61bf16ae34..9d1202e1ed6 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1612,7 +1612,7 @@ function StreamlinedIssuesList({ // navigate so keyboard-driven opens paint from cache instantly, the // same way pointer hover/click does through the issue link. Mirrors // the inbox Enter handler. - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue, history: true }); navigate(createIssueDetailPath(pathId), { state: detailState }); break; } diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index 2c2873d39de..0c72812c7c6 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -1498,7 +1498,7 @@ export function IssuesList({ // navigate so keyboard-driven opens paint from cache instantly, the // same way pointer hover/click does through the issue link. Mirrors // the inbox Enter handler. - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue, history: true }); navigate(createIssueDetailPath(pathId), { state: detailState }); break; } diff --git a/ui/src/hooks/useIssueIdBatches.test.tsx b/ui/src/hooks/useIssueIdBatches.test.tsx index 367f1542010..f7ff048c39e 100644 --- a/ui/src/hooks/useIssueIdBatches.test.tsx +++ b/ui/src/hooks/useIssueIdBatches.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { expect, it, vi } from "vitest"; -import { useIssueIdBatches } from "./useIssueIdBatches"; +import { prefetchIssueIdBatches, useIssueIdBatches } from "./useIssueIdBatches"; it("requests metadata only for newly revealed ids and refreshes existing batches on invalidation", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -25,3 +25,25 @@ it("requests metadata only for newly revealed ids and refreshes existing batches expect(fetch.mock.calls.flatMap(([batch]) => batch)).toHaveLength(340); } finally { await act(async () => root.unmount()); client.clear(); } }); + +it("renders prefetched batches from cache on the first render", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(document.createElement("div")); + const fetch = vi.fn(async (ids: string[]) => ids.map(id => ({ id }))); + const ids = Array.from({ length: 130 }, (_, index) => String(index).padStart(3, "0")); + await prefetchIssueIdBatches(client, ["metadata", "chat"], ids, fetch, 30_000); + expect(fetch).toHaveBeenCalledTimes(2); + const renders: Array<{ isLoading: boolean; size: number }> = []; + function Probe() { + const current = useIssueIdBatches(["metadata", "chat"], ids, fetch, true); + renders.push({ isLoading: current.isLoading, size: current.data.length }); + return null; + } + try { + await act(async () => root.render()); + // The first render already has every batch, so nothing gates on a fetch. + // A background refresh of the stale batches may follow, as for any query. + expect(renders[0]).toEqual({ isLoading: false, size: 130 }); + expect(fetch.mock.calls.slice(0, 2).map(([batch]) => batch.length)).toEqual([100, 30]); + } finally { await act(async () => root.unmount()); client.clear(); } +}); diff --git a/ui/src/hooks/useIssueIdBatches.ts b/ui/src/hooks/useIssueIdBatches.ts index 16b25abdfaa..ca476ca4d63 100644 --- a/ui/src/hooks/useIssueIdBatches.ts +++ b/ui/src/hooks/useIssueIdBatches.ts @@ -1,5 +1,38 @@ import { useEffect, useMemo } from "react"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueries, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; + +const ISSUE_ID_BATCH_SIZE = 100; + +/** Append the ids not yet in any batch, as new batches of at most 100 ids. */ +function appendIssueIdBatches(current: string[][] | undefined, ids: readonly string[]) { + const existing = new Set(current?.flat() ?? []); + const incoming = ids.filter(id => !existing.has(id)); + if (!incoming.length) return current; + const appended: string[][] = []; + for (let index = 0; index < incoming.length; index += ISSUE_ID_BATCH_SIZE) { + appended.push(incoming.slice(index, index + ISSUE_ID_BATCH_SIZE)); + } + return [...current ?? [], ...appended]; +} + +/** + * Warm the batches that `useIssueIdBatches` reads for these ids, under the same + * keys, so a page that mounts with them renders from cache. + */ +export function prefetchIssueIdBatches( + client: QueryClient, + key: readonly unknown[], + ids: readonly string[], + fetchBatch: (ids: string[]) => Promise, + staleTime: number, +) { + if (!ids.length) return Promise.resolve(); + const batches = client.setQueryData([...key, "batch-ids"], current => appendIssueIdBatches(current, ids)) ?? []; + const wanted = new Set(ids); + return Promise.all(batches.filter(batch => batch.some(id => wanted.has(id))).map(batch => client.prefetchQuery({ + queryKey: [...key, "batch", batch], queryFn: () => fetchBatch(batch), staleTime, + }))).then(() => undefined); +} export function useIssueIdBatches(key: readonly unknown[], ids: string[], fetchBatch: (ids: string[]) => Promise, enabled: boolean) { const client = useQueryClient(); @@ -13,14 +46,7 @@ export function useIssueIdBatches(key: readonly unknown[], ids: string[], fet const missingKey = JSON.stringify(missing); useEffect(() => { if (!enabled || missingKey === "[]") return; - client.setQueryData([...base, "batch-ids"], current => { - const existing = new Set(current?.flat() ?? []); - const incoming = (JSON.parse(missingKey) as string[]).filter(id => !existing.has(id)); - if (!incoming.length) return current; - const appended: string[][] = []; - for (let index = 0; index < incoming.length; index += 100) appended.push(incoming.slice(index, index + 100)); - return [...current ?? [], ...appended]; - }); + client.setQueryData([...base, "batch-ids"], current => appendIssueIdBatches(current, JSON.parse(missingKey) as string[])); }, [client, base, missingKey, enabled]); const queries = useQueries({ queries: batches.map(batch => ({ queryKey: [...base, "batch", batch], queryFn: () => fetchBatch(batch), enabled, diff --git a/ui/src/lib/issue-chat-history-queries.ts b/ui/src/lib/issue-chat-history-queries.ts new file mode 100644 index 00000000000..69bbc39b15f --- /dev/null +++ b/ui/src/lib/issue-chat-history-queries.ts @@ -0,0 +1,151 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { ActivityEvent, Issue, IssueComment, IssueRecentComments } from "@paperclipai/shared"; +import { activityApi, type RunForIssue } from "@/api/activity"; +import { heartbeatsApi } from "@/api/heartbeats"; +import { issuesApi } from "@/api/issues"; +import { prefetchIssueIdBatches } from "@/hooks/useIssueIdBatches"; +import { queryKeys } from "@/lib/queryKeys"; + +// The compact task chat reveals its first frame only after these reads settle +// (see `initialHistoryPending` in IssueDetail). The page and navigation prefetch +// both build them here, so a prefetch fills exactly the keys the page reads. + +export interface RecentIssueRunWindow { + runs: RunForIssue[]; + hasEarlierRuns: boolean; +} + +export function recentIssueActivityQueryKey(issueRef: string) { + return [...queryKeys.issues.activity(issueRef), "recent"] as const; +} + +export function recentIssueRunWindowQueryKey(issueRef: string) { + return [...queryKeys.issues.runs(issueRef), "recent-window"] as const; +} + +/** Base key of the batched input facts for the comments in the chat window. */ +export function issueCommentInputFactsQueryKey(issueRef: string) { + return [...queryKeys.issues.activity(issueRef), "comment-inputs"] as const; +} + +/** The saved (not optimistic) comment ids whose input facts the chat reads, sorted. */ +export function issueInputCommentIds(comments: readonly IssueComment[]) { + return comments + .filter((comment) => !("clientStatus" in comment) || !comment.clientStatus) + .map((comment) => comment.id) + .sort(); +} + +/** + * Warm the input facts of the recent comments already in cache, under the + * batch keys that `useIssueIdBatches` reads in the chat. + */ +export function prefetchIssueCommentInputFacts(queryClient: QueryClient, issueRef: string, staleTime: number) { + const recent = queryClient.getQueryData([...queryKeys.issues.comments(issueRef), "recent"]); + return prefetchIssueIdBatches( + queryClient, + issueCommentInputFactsQueryKey(issueRef), + issueInputCommentIds(recent?.comments ?? []), + (ids) => activityApi.inputFactsForIssue(issueRef, ids), + staleTime, + ); +} + +/** Recent activity, merged with events already observed while the task is open. */ +export async function fetchRecentIssueActivity(queryClient: QueryClient, issueRef: string) { + const recent = await activityApi.recentForIssue(issueRef); + const previous = queryClient.getQueryData(recentIssueActivityQueryKey(issueRef)) ?? []; + return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()]; +} + +/** The latest run, plus every run observed while the task is open. */ +export async function fetchRecentIssueRunWindow( + queryClient: QueryClient, + issueRef: string, + options: { conversationMode?: boolean } = {}, +): Promise { + const key = recentIssueRunWindowQueryKey(issueRef); + let recent = await activityApi.recentRunsForIssue(issueRef); + const previousWindow = queryClient.getQueryData(key); + let previous = previousWindow?.runs ?? []; + let reconciledFullHistory = false; + if (previous.some((run) => + (run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId), + )) { + // After a reconnect, several turns may have completed beyond the small + // window. Reconcile watched runs once against authoritative summaries. + recent = options.conversationMode + ? await activityApi.runsByIdsForIssue(issueRef, [...new Set([...previous.map(run => run.runId), ...recent.map(run => run.runId)])]) + : await activityApi.runsForIssue(issueRef); + reconciledFullHistory = true; + const existingRunIds = new Set(recent.map((run) => run.runId)); + previous = previous.filter((run) => existingRunIds.has(run.runId)); + } + // The second row refreshes a just-finished predecessor. Start with only + // the latest run, then keep every run observed while this task is open. + const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]); + return { + runs: [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()], + // A first run is already visible. The disclosure needs evidence of an + // omitted run, and must retain that evidence as later turns arrive. + hasEarlierRuns: recent.some((run) => !visible.has(run.runId)) + || (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true), + }; +} + +/** Prefetched answers for live state stay usable only briefly. */ +const LIVE_STATE_PREFETCH_STALE_TIME_MS = 5_000; + +/** + * Warm the reads that gate the compact chat's first frame, so a task the person + * is about to open renders from cache. Comments and the core issue are warmed by + * `prefetchIssueDetailForNavigation`; this adds the rest of the gate. + */ +export function prefetchIssueChatHistory( + queryClient: QueryClient, + issueRef: string, + options: { + issue?: Pick | null; + staleTime: number; + }, +) { + const { staleTime } = options; + // Same mode as the page, so a reconnect reconciles only the watched runs. + const conversationMode = !!options.issue?.conversationAgentId; + const reads: Promise[] = [ + queryClient.prefetchQuery({ + queryKey: recentIssueActivityQueryKey(issueRef), + queryFn: () => fetchRecentIssueActivity(queryClient, issueRef), + staleTime, + }), + queryClient.prefetchQuery({ + queryKey: recentIssueRunWindowQueryKey(issueRef), + queryFn: () => fetchRecentIssueRunWindow(queryClient, issueRef, { conversationMode }), + staleTime, + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.issues.interactions(issueRef), + queryFn: () => issuesApi.listInteractions(issueRef), + staleTime, + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.issues.attachments(issueRef), + queryFn: () => issuesApi.listAttachments(issueRef), + staleTime, + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.issues.liveRuns(issueRef), + queryFn: () => heartbeatsApi.liveRunsForIssue(issueRef), + staleTime: LIVE_STATE_PREFETCH_STALE_TIME_MS, + }), + ]; + // The page reads the active run only for work that is running. + if (options.issue && (options.issue.executionRunId || options.issue.status === "in_progress")) { + reads.push(queryClient.prefetchQuery({ + queryKey: queryKeys.issues.activeRun(issueRef), + queryFn: () => heartbeatsApi.activeRunForIssue(issueRef), + staleTime: LIVE_STATE_PREFETCH_STALE_TIME_MS, + })); + } + return Promise.all(reads); +} diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index a6713fbcf6a..290eab37b15 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -2,6 +2,13 @@ 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 { activityApi } from "@/api/activity"; +import { heartbeatsApi } from "@/api/heartbeats"; +import { + issueCommentInputFactsQueryKey, + recentIssueActivityQueryKey, + recentIssueRunWindowQueryKey, +} from "./issue-chat-history-queries"; import { fetchIssueDetail, getCachedIssueDetail, @@ -17,8 +24,16 @@ vi.mock("@/api/issues", () => ({ getCore: vi.fn(), recentComments: vi.fn(), listComments: vi.fn(), + listInteractions: vi.fn(), + listAttachments: vi.fn(), }, })); +vi.mock("@/api/activity", () => ({ + activityApi: { recentForIssue: vi.fn(), recentRunsForIssue: vi.fn(), runsForIssue: vi.fn(), runsByIdsForIssue: vi.fn(), inputFactsForIssue: vi.fn() }, +})); +vi.mock("@/api/heartbeats", () => ({ + heartbeatsApi: { liveRunsForIssue: vi.fn(), activeRunForIssue: vi.fn() }, +})); function createIssue(overrides: Partial = {}): Issue { return { @@ -179,4 +194,98 @@ describe("task navigation prefetch", () => { } finally { client.clear(); } }); + describe("chat history for a likely open", () => { + const run = (runId: string, status: string) => ({ runId, status, agentId: "agent-1", adapterType: "codex_local", createdAt: "2026-11-09T00:00:00Z" }); + function mockHistoryReads() { + const issue = createIssue(); + vi.mocked(issuesApi.getCore).mockResolvedValue(issue); + vi.mocked(issuesApi.recentComments).mockResolvedValue({ comments: [], latestCommentId: null, oldestCommentId: null, hasMore: false }); + vi.mocked(activityApi.recentForIssue).mockResolvedValue([{ id: "event-1" }] as never); + vi.mocked(activityApi.recentRunsForIssue).mockResolvedValue([run("run-2", "failed"), run("run-1", "succeeded")] as never); + vi.mocked(issuesApi.listInteractions).mockResolvedValue([]); + vi.mocked(issuesApi.listAttachments).mockResolvedValue([]); + vi.mocked(heartbeatsApi.liveRunsForIssue).mockResolvedValue([]); + vi.mocked(heartbeatsApi.activeRunForIssue).mockResolvedValue(null); + return issue; + } + + it("fills every key the compact chat waits on before its first reveal", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = mockHistoryReads(); + const ref = issue.identifier!; + try { + await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); + expect(client.getQueryData(recentIssueActivityQueryKey(ref))).toEqual([{ id: "event-1" }]); + // Same window shape the page builds: the latest run, plus evidence of earlier runs. + expect(client.getQueryData(recentIssueRunWindowQueryKey(ref))).toEqual({ runs: [run("run-2", "failed")], hasEarlierRuns: true }); + expect(client.getQueryData(queryKeys.issues.interactions(ref))).toEqual([]); + expect(client.getQueryData(queryKeys.issues.attachments(ref))).toEqual([]); + expect(client.getQueryData(queryKeys.issues.liveRuns(ref))).toEqual([]); + // A task that is not running has no active run for the page to read. + expect(heartbeatsApi.activeRunForIssue).not.toHaveBeenCalled(); + } finally { client.clear(); } + }); + + it("reads the active run only for running work", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = { ...mockHistoryReads(), status: "in_progress" as const }; + try { + await prefetchIssueDetailForNavigation(client, issue.identifier!, { issue, history: true }); + expect(heartbeatsApi.activeRunForIssue).toHaveBeenCalledWith(issue.identifier); + expect(client.getQueryData(queryKeys.issues.activeRun(issue.identifier!))).toBeNull(); + } finally { client.clear(); } + }); + + it("warms the input facts of the recent comments under the chat's batch keys", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = mockHistoryReads(); + const ref = issue.identifier!; + const comment = (id: string) => ({ id, body: id, createdAt: "2026-11-09T00:00:00Z" }); + vi.mocked(issuesApi.recentComments).mockResolvedValue({ + comments: [comment("comment-b"), comment("comment-a")], + latestCommentId: "comment-b", oldestCommentId: "comment-a", hasMore: false, + } as never); + vi.mocked(activityApi.inputFactsForIssue).mockResolvedValue([{ id: "input-1" }] as never); + try { + await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); + // The same ids, order and keys that the chat's useIssueIdBatches reads. + expect(activityApi.inputFactsForIssue).toHaveBeenCalledWith(ref, ["comment-a", "comment-b"]); + const key = issueCommentInputFactsQueryKey(ref); + expect(client.getQueryData([...key, "batch-ids"])).toEqual([["comment-a", "comment-b"]]); + expect(client.getQueryData([...key, "batch", ["comment-a", "comment-b"]])).toEqual([{ id: "input-1" }]); + } finally { client.clear(); } + }); + + it("reconciles a watched agent conversation's runs by id, not by reading every run", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = { ...mockHistoryReads(), conversationAgentId: "agent-1" }; + const ref = issue.identifier!; + // A run watched earlier is still running, but has left the recent window. + client.setQueryData(recentIssueRunWindowQueryKey(ref), { runs: [run("run-0", "running")], hasEarlierRuns: false }); + client.getQueryCache().find({ queryKey: recentIssueRunWindowQueryKey(ref) })!.invalidate(); + vi.mocked(activityApi.runsByIdsForIssue).mockResolvedValue([run("run-2", "failed"), run("run-0", "succeeded")] as never); + try { + await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); + expect(activityApi.runsByIdsForIssue).toHaveBeenCalledWith(ref, ["run-0", "run-2", "run-1"]); + expect(activityApi.runsForIssue).not.toHaveBeenCalled(); + } finally { client.clear(); } + }); + + it("keeps a pointer passing over a row to the issue and recent comments", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = mockHistoryReads(); + try { + await prefetchIssueDetailForNavigation(client, issue.identifier!, { issue }); + expect(activityApi.recentForIssue).not.toHaveBeenCalled(); + expect(activityApi.recentRunsForIssue).not.toHaveBeenCalled(); + expect(issuesApi.listInteractions).not.toHaveBeenCalled(); + expect(heartbeatsApi.liveRunsForIssue).not.toHaveBeenCalled(); + } finally { client.clear(); } + }); + }); }); diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index cb05ae4e066..a22e7e9334c 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { InstanceExperimentalSettings, Issue, IssueComment } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; import { getIssueRecentCommentsQueryOptions } from "./issue-comment-window-query"; +import { prefetchIssueChatHistory, prefetchIssueCommentInputFacts } from "./issue-chat-history-queries"; import { queryKeys } from "@/lib/queryKeys"; import { getNextIssueCommentPageParam, ISSUE_COMMENT_PAGE_SIZE } from "@/lib/optimistic-issue-comments"; @@ -224,10 +225,20 @@ export function prefetchIssueComments(queryClient: QueryClient, issueRef: string }); } +/** + * Warm what a task's first frame needs before the person opens it. + * + * `history: true` also warms every other read that gates the compact chat's + * first reveal (activity, runs, comment input facts, interactions, + * attachments, live runs). Callers + * pass it when an open is likely: a mouse or pen press, a click, keyboard + * selection, or a hover or focus that rests on the link. A pointer, touch or + * focus passing over rows warms only the issue and its recent comments. + */ export function prefetchIssueDetailForNavigation( queryClient: QueryClient, issueRef: string, - options?: { issue?: Issue | null }, + options?: { issue?: Issue | null; history?: boolean }, ) { const fullHistory = queryClient.getQueryData(["issues", "history-expanded", issueRef]) === true || queryClient.getQueryData(queryKeys.instance.experimentalSettings)?.enableClassicTaskInterface === true; @@ -248,6 +259,15 @@ export function prefetchIssueDetailForNavigation( queryClient.prefetchQuery({ ...getIssueRecentCommentsQueryOptions(queryClient, issueRef), staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, - }), + }).then(() => options?.history + // The chat also waits for the input facts of the comments it shows. + ? prefetchIssueCommentInputFacts(queryClient, issueRef, ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS) + : undefined), + ...(options?.history + ? [prefetchIssueChatHistory(queryClient, issueRef, { + issue: options.issue ?? getCachedIssueDetail(queryClient, issueRef), + staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, + })] + : []), ]); } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ae190ee707e..45f96af67e2 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -2466,7 +2466,7 @@ function StreamlinedInbox({ const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue)); rememberIssueDetailLocationState(pathId, detailState); - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue, history: true }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { @@ -2475,7 +2475,7 @@ function StreamlinedInbox({ withIssueDetailHeaderSeed(issueLinkState, item.issue), ); rememberIssueDetailLocationState(pathId, detailState); - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue, history: true }); 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 d7161c31b98..2f6ee9582e1 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -77,6 +77,15 @@ import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation"; import { describeInteractionResolutionFailure, interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; +import { + fetchRecentIssueActivity, + fetchRecentIssueRunWindow, + issueCommentInputFactsQueryKey, + issueInputCommentIds, + recentIssueActivityQueryKey, + recentIssueRunWindowQueryKey, + type RecentIssueRunWindow, +} from "../lib/issue-chat-history-queries"; import { heartbeatsApi, type ActiveRunForIssue, @@ -1182,11 +1191,6 @@ function InboxMobileToolbar({ ); } -interface RecentIssueRunWindow { - runs: RunForIssue[]; - hasEarlierRuns: boolean; -} - type IssueDetailChatTabProps = { executionBlocked?: boolean; interruption?: Issue["interruption"]; @@ -1486,21 +1490,18 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ isError: activityError, refetch: refetchActivity, } = useQuery({ - queryKey: historyExpanded ? queryKeys.issues.activity(issueQueryRef) : [...queryKeys.issues.activity(issueQueryRef), "recent"], - queryFn: async () => { - if (historyExpanded) return activityApi.forIssue(issueQueryRef); - const recent = await activityApi.recentForIssue(issueQueryRef); - const previous = queryClient.getQueryData([...queryKeys.issues.activity(issueQueryRef), "recent"]) ?? []; - return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()]; - }, + queryKey: historyExpanded ? queryKeys.issues.activity(issueQueryRef) : recentIssueActivityQueryKey(issueQueryRef), + queryFn: () => historyExpanded + ? activityApi.forIssue(issueQueryRef) + : fetchRecentIssueActivity(queryClient, issueQueryRef), enabled: !!issueId, placeholderData: historyExpanded ? queryClient.getQueryData([...queryKeys.issues.activity(issueQueryRef), "recent"]) : undefined, }); - const inputCommentIds = comments.filter((comment) => !("clientStatus" in comment) || !comment.clientStatus).map((comment) => comment.id).sort(); + const inputCommentIds = issueInputCommentIds(comments); const inputFacts = useIssueIdBatches( - [...queryKeys.issues.activity(issueQueryRef), "comment-inputs"], inputCommentIds, + issueCommentInputFactsQueryKey(issueQueryRef), inputCommentIds, ids => activityApi.inputFactsForIssue(issueQueryRef, ids), !!issueId && !historyExpanded, ); const { @@ -1607,7 +1608,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const linkedRunsKey = historyExpanded ? queryKeys.issues.runs(issueQueryRef) - : [...queryKeys.issues.runs(issueQueryRef), "recent-window"]; + : recentIssueRunWindowQueryKey(issueQueryRef); const { data: linkedRunHistory, isPending: linkedRunsPending, @@ -1615,35 +1616,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetch: refetchLinkedRuns, } = useQuery({ queryKey: linkedRunsKey, - queryFn: async () => { - if (historyExpanded) return activityApi.runsForIssue(issueQueryRef); - let recent = await activityApi.recentRunsForIssue(issueQueryRef); - const previousWindow = queryClient.getQueryData(linkedRunsKey); - let previous = previousWindow?.runs ?? []; - let reconciledFullHistory = false; - if (previous.some((run) => - (run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId), - )) { - // After a reconnect, several turns may have completed beyond the small - // window. Reconcile watched runs once against authoritative summaries. - recent = conversationMode - ? await activityApi.runsByIdsForIssue(issueQueryRef, [...new Set([...previous.map(run => run.runId), ...recent.map(run => run.runId)])]) - : await activityApi.runsForIssue(issueQueryRef); - reconciledFullHistory = true; - const existingRunIds = new Set(recent.map((run) => run.runId)); - previous = previous.filter((run) => existingRunIds.has(run.runId)); - } - // The second row refreshes a just-finished predecessor. Start with only - // the latest run, then keep every run observed while this task is open. - const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]); - return { - runs: [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()], - // A first run is already visible. The disclosure needs evidence of an - // omitted run, and must retain that evidence as later turns arrive. - hasEarlierRuns: recent.some((run) => !visible.has(run.runId)) - || (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true), - }; - }, + queryFn: () => historyExpanded + ? activityApi.runsForIssue(issueQueryRef) + : fetchRecentIssueRunWindow(queryClient, issueQueryRef, { conversationMode }), enabled: !!issueId, refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 1000 : false, diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index 150af787098..6bfde7ff373 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -2188,7 +2188,7 @@ export function Inbox() { const pathId = issue.identifier ?? issue.id; const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue)); rememberIssueDetailLocationState(pathId, detailState); - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue, history: true }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item) { if (item.kind === "issue") { @@ -2197,7 +2197,7 @@ export function Inbox() { withIssueDetailHeaderSeed(issueLinkState, item.issue), ); rememberIssueDetailLocationState(pathId, detailState); - void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue }); + void prefetchIssueDetailForNavigation(queryClient, pathId, { issue: item.issue, history: true }); act.navigate(createIssueDetailPath(pathId), { state: detailState }); } else if (item.kind === "approval") { act.navigate(`/approvals/${item.approval.id}`);