From 189fefb99183953be37f0d1084275fbb0f35d6e2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:26:42 -0600 Subject: [PATCH] perf(task-chat): warm the whole first frame before a task opens (#20286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from the issues list and the inbox to continue a conversation with an agent > - The compact task chat shows its first frame only after about ten reads settle. These include recent comments, activity, recent runs, interactions, attachments and live runs > - Navigation prefetch warmed only the issue and its recent comments. The other reads started on the click, and the slowest one decided when the chat appeared. On staging that took 1.1–2.0 s > - This pull request warms the whole first-frame set when an open is likely, under the exact keys the page reads > - The benefit is that a task the person is about to open can render from cache. A pointer that only passes over rows does not add load ## Linked Issues or Issue Description **Problem or motivation** On a busy instance, the composer and the latest messages appear 1.1–2.0 s after a click on a task row. The page waits for about ten reads. Before the click, only two of them are warmed. **Proposed solution** Warm the rest of the first-frame reads when an open is likely: a mouse or pen press, a click, keyboard Enter, or a hover or focus that rests on the link for 120 ms. The page and the prefetch use the same fetchers and query keys. **Alternatives considered** - Warm everything on every hover. This sends about 7 requests for each row that a pointer crosses, so it was rejected. - Warm only on press. The time from press to click is short, so most of the wait would remain. - A single bootstrap endpoint. This is planned separately, because it changes the API contract. **Roadmap alignment** This is part of the task-loading performance plan in #20276. It follows #20277 and #20284. ## What Changed - New `ui/src/lib/issue-chat-history-queries.ts`: - The recent activity and recent run window fetchers moved here from `IssueDetail.tsx`, together with their query keys. The logic is unchanged. - `prefetchIssueChatHistory` warms activity, the run window, interactions, attachments and live runs. It warms the active run only for running work, as the page does. - Live state uses a 5 s stale time. The other reads use the existing 30 s navigation stale time. - The run-window prefetch uses the same conversation mode as the page. Thus a reconnect in an agent conversation reads only the watched runs by id. - `prefetchIssueCommentInputFacts` warms the input facts of the recent comments after those comments arrive. - `ui/src/hooks/useIssueIdBatches.ts`: new `prefetchIssueIdBatches` fills the same batch keys that the hook reads. The hook and the prefetch share one batching function. - `prefetchIssueDetailForNavigation` accepts `history: true` and then also warms that set, plus the comment input facts. - `IssueLinkQuicklook`: - A hover still warms the light set at once. The quicklook trigger does not start a history prefetch. - A hover that rests on the link for 120 ms warms the full set. Leaving the link before 120 ms cancels it. - Keyboard focus uses the same 120 ms dwell as a hover. Blur cancels it. - A primary mouse or pen press and a click warm the full set at once. - A touch can start a scroll, so it warms only the light set. The full set waits for the click. - The keyboard Enter handlers in `IssuesList`, `LegacyIssuesList`, `Inbox` and `LegacyInbox` pass `history: true`. - `IssueDetail.tsx` uses the shared fetchers and keys. ## Verification - `cd ui && npx vitest run src/lib/issueDetailCache.test.ts src/components/IssueLinkQuicklook.test.tsx src/hooks/useIssueIdBatches.test.tsx` passes 29 tests. These are the new ones: - A `history` prefetch fills every key that the compact chat waits on, with the same run-window shape that the page builds. - A `history` prefetch fills the comment input-fact batch keys that the chat reads. - In an agent conversation, a reconnect reads the watched runs by id, not every run. - Prefetched id batches render with no loading state on the first render. - The active run is read only for work that is running. - A light prefetch makes no history requests. - A pointer that passes over a link in less than 120 ms warms only the light set, with the quicklook on and off. - A hover that rests on a link warms the full set. - A mouse press warms the full set at once. - A touch warms only the light set until the click. - Keyboard focus warms the full set only after it rests on the link. - The tests for the review fixes (quicklook hover, input facts, conversation mode, touch and focus) fail without the fix. - The `IssueDetail`, `Inbox`, `IssuesList` and `LegacyInbox` suites pass: 4 files, 412 tests. - `pnpm typecheck` in `ui` shows no new errors. The only errors are existing ones from an unbuilt `plugin-sdk` package in this worktree. - The staging harness from #20276 will measure the gain after deploy, for a hover, a click, and a revisit. ## Risks - More requests when an open is likely: up to 7 extra reads for each task that is pressed with a mouse or pen, or hovered or focused for 120 ms. These are the same reads that the page would send on open, so a completed open costs nothing extra. A hover that does not lead to an open costs those reads. - Freshness: a prefetched result can stay fresh for up to 30 s (5 s for live state). Websocket invalidation still refreshes these keys, as on a revisit today. - No API or schema change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, browser automation, file editing). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- ui/src/components/IssueLinkQuicklook.test.tsx | 92 ++++++++++- ui/src/components/IssueLinkQuicklook.tsx | 49 ++++-- ui/src/components/IssuesList.tsx | 2 +- ui/src/components/LegacyIssuesList.tsx | 2 +- ui/src/hooks/useIssueIdBatches.test.tsx | 24 ++- ui/src/hooks/useIssueIdBatches.ts | 44 +++-- ui/src/lib/issue-chat-history-queries.ts | 151 ++++++++++++++++++ ui/src/lib/issueDetailCache.test.ts | 109 +++++++++++++ ui/src/lib/issueDetailCache.ts | 24 ++- ui/src/pages/Inbox.tsx | 4 +- ui/src/pages/IssueDetail.tsx | 63 +++----- ui/src/pages/LegacyInbox.tsx | 4 +- 12 files changed, 495 insertions(+), 73 deletions(-) create mode 100644 ui/src/lib/issue-chat-history-queries.ts 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}`);