Files
PaperClipAI/ui/src/components/IssueLinkQuicklook.tsx
T
DottaandClaude Opus 5.5 189fefb991 perf(task-chat): warm the whole first frame before a task opens (#20286)
## 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 <noreply@anthropic.com>
2026-11-09 10:26:42 -06:00

485 lines
18 KiB
TypeScript

import * as React from "react";
import { useMemo } from "react";
import * as RouterDom from "react-router-dom";
import type { Issue } from "@paperclipai/shared";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { timeAgo } from "@/lib/timeAgo";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
import {
getIssueDetailQueryOptions,
ISSUE_DETAIL_STALE_TIME_MS,
prefetchIssueDetailForNavigation,
} from "@/lib/issueDetailCache";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusGlyph } from "@/components/StatusGlyph";
import { ProjectTile } from "@/components/ProjectTile";
import { useFlyoutAppearance } from "@/components/ui/flyout-appearance";
/* ------------------------------------------------------------------ */
/* Single-flight quicklook store */
/* */
/* Every issue link renders its own Radix Popover. In a dense list */
/* (e.g. the Conference Room agent feed) independent per-card open */
/* state lets popovers overlap, linger, and stack — two flyouts at */
/* once, sometimes showing the wrong card. This module-level store */
/* enforces exactly one open quicklook across the whole tree: opening */
/* one closes any other. */
/* ------------------------------------------------------------------ */
let activeQuicklookId: symbol | null = null;
const quicklookListeners = new Set<() => void>();
function emitQuicklookChange() {
for (const listener of quicklookListeners) listener();
}
function openQuicklookId(id: symbol) {
if (activeQuicklookId === id) return;
activeQuicklookId = id;
emitQuicklookChange();
}
function closeQuicklookId(id: symbol) {
if (activeQuicklookId !== id) return;
activeQuicklookId = null;
emitQuicklookChange();
}
function subscribeQuicklook(listener: () => void) {
quicklookListeners.add(listener);
return () => {
quicklookListeners.delete(listener);
};
}
function useIsQuicklookOpen(id: symbol) {
return React.useSyncExternalStore(
subscribeQuicklook,
() => activeQuicklookId === id,
() => false,
);
}
/** Hover-intent delay (ms) before a quicklook opens — prevents flicker
* as the pointer crosses cards on its way somewhere else. */
const QUICKLOOK_OPEN_DELAY_MS = 120;
/**
* Distance from the quicklook's outer edge to its first column of text: the
* `p-3` padding in `QUICKLOOK_CONTENT_CLASS` (12px) plus the 1px border that
* `PopoverContent` draws. Both sit between the box edge and the content, so
* both have to be cancelled to line the text up with the trigger's.
*/
const QUICKLOOK_CONTENT_INSET_PX = 12 + 1;
/** Shared shell for every surface that renders `IssueQuicklookCard`. */
export const QUICKLOOK_CONTENT_CLASS = "w-72 p-3";
/**
* Cancel the card's own inset along the align axis, so the preview lines up
* with the *text* that opened it rather than with that text's box.
*
* Radix aligns box to box: with `align="start"` the card's left edge meets the
* trigger's left edge, which leaves the card's text pushed right by its border
* and padding — visibly off against the task key above it. Pulling the box back
* by exactly that inset puts the card's first column of text on the same
* vertical line as the trigger's. `end` needs the mirror of it; `center` has no
* edge to line up with, so it gets nothing.
*/
export function quicklookAlignOffset(align: "start" | "center" | "end" = "start"): number {
if (align === "start") return -QUICKLOOK_CONTENT_INSET_PX;
if (align === "end") return QUICKLOOK_CONTENT_INSET_PX;
return 0;
}
export type IssueQuicklookIssue = Pick<Issue, "id" | "title" | "updatedAt"> & {
identifier?: string | null;
status: string;
priority: string;
description?: string | null;
blockerAttention?: Issue["blockerAttention"];
projectId?: string | null;
project?: { name?: string | null } | null;
originKind?: string;
originId?: string | null;
};
function summarizeIssueDescription(description: string | null | undefined) {
if (!description) return null;
const summary = description
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!summary) return null;
return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary;
}
/** "·" between facts in the quicklook's meta and status lines. */
function QuicklookSeparator({ className }: { className?: string }) {
return (
<span className={cn("text-xs", className)} aria-hidden>
·
</span>
);
}
/** "in_review" -> "In review". The card states the status as a word, not a chip. */
function statusLabel(status: string): string {
const words = status.replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
/**
* The standard task preview card. Every hover preview of a task in the app
* renders this, so the same three rows appear in the same order everywhere:
*
* 1. meta — status glyph · task key [· project] .......... last activity
* 2. title
* 3. summary — first lines of the description
*
* The meta row splits: identity on the left, recency pinned right. Identity
* leads because a preview answers "which task is this?", and the title alone
* does not.
*
* Status carries no word of its own — the glyph is the status, exactly as it is
* on task rows and decision cards. Because that leaves shape and colour as the
* only visual signal, the glyph is given a `title`, so it renders as
* `role="img"` with the status as its accessible name. The status stays
* available to a screen reader without spending a line on it.
*
* Three shapes the row must hold, per the design mock:
* 1. no project — glyph, key, and the timestamp hard right
* 2. project — a "·" and the project tile plus name join the left group
* 3. truncation — a long project name ellipsizes; the key and the timestamp
* never do, so the two facts that identify the task survive
* at any width
*/
export function IssueQuicklookCard({
issue,
linkTo,
linkState,
compact = false,
}: {
issue: IssueQuicklookIssue;
linkTo: RouterDom.To;
linkState?: unknown;
compact?: boolean;
}) {
const appearance = useFlyoutAppearance();
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
const projectName = issue.project?.name;
return (
<div className={appearance === "refreshed" ? "task-hover-card" : cn("space-y-2", compact && "space-y-1.5")}>
<div data-slot="task-preview-meta" className="flex items-center gap-1">
<div data-slot="task-preview-identity" className="flex min-w-0 flex-1 items-center gap-1">
<StatusGlyph status={issue.status} size="md" title={statusLabel(issue.status)} />
<span data-slot="task-preview-id" className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{projectName && appearance !== "refreshed" ? (
<>
<QuicklookSeparator className="shrink-0 text-muted-foreground" />
<span
className="flex min-w-0 max-w-(--sz-12rem) flex-1 items-center gap-1 text-(length:--text-micro) text-muted-foreground"
title={projectName}
data-testid="quicklook-project"
>
<ProjectTile size="xs" />
<span className="truncate">{projectName}</span>
</span>
</>
) : null}
</div>
<span data-slot="task-preview-time" className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{timeAgo(new Date(issue.updatedAt))}
</span>
</div>
<RouterDom.Link
data-slot="task-preview-title"
to={linkTo}
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
className="block text-sm font-medium leading-snug hover:underline line-clamp-2"
>
{issue.title}
</RouterDom.Link>
{description ? (
<p data-slot="task-preview-description" className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
{description}
</p>
) : null}
</div>
);
}
/** 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<typeof RouterDom.Link> & {
issuePathId: string;
disableIssueQuicklook?: boolean;
issuePrefetch?: Issue | null;
issueQuicklookSide?: React.ComponentProps<typeof PopoverContent>["side"];
issueQuicklookAlign?: React.ComponentProps<typeof PopoverContent>["align"];
}
>(function IssueLinkQuicklookImpl(
{
issuePathId,
to,
children,
className,
state,
disableIssueQuicklook = false,
issuePrefetch = null,
issueQuicklookSide = "top",
issueQuicklookAlign = "start",
onClick,
onClickCapture,
onMouseEnter,
onMouseLeave,
onPointerDown,
onFocus,
onBlur,
onTouchStart,
...props
},
ref,
) {
const queryClient = useQueryClient();
const instanceId = React.useMemo(() => Symbol("issue-quicklook"), []);
const open = useIsQuicklookOpen(instanceId);
const openTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = React.useRef<HTMLAnchorElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
// Keep the caller's ref working while we also hold our own handle on the
// trigger node (needed by the pointer-escape guard below).
const setTriggerRef = React.useCallback(
(node: HTMLAnchorElement | null) => {
triggerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const cancelScheduledOpen = React.useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
// Open immediately (keyboard focus, or re-entering the open popover).
const openNow = React.useCallback(() => {
cancelScheduledOpen();
openQuicklookId(instanceId);
}, [cancelScheduledOpen, instanceId]);
// Open after the hover-intent delay (pointer entering a card).
const scheduleOpen = React.useCallback(() => {
cancelScheduledOpen();
openTimerRef.current = setTimeout(() => {
openTimerRef.current = null;
openQuicklookId(instanceId);
}, QUICKLOOK_OPEN_DELAY_MS);
}, [cancelScheduledOpen, instanceId]);
const close = React.useCallback(() => {
cancelScheduledOpen();
closeQuicklookId(instanceId);
}, [cancelScheduledOpen, instanceId]);
// Clear any pending timer and release the active slot on unmount.
React.useEffect(() => {
return () => {
cancelScheduledOpen();
closeQuicklookId(instanceId);
};
}, [cancelScheduledOpen, instanceId]);
// Pointer-escape guard.
//
// The only close paths are `mouseleave` on the trigger and on the content,
// and a leave event is not guaranteed: when the layout shifts, the element
// moves out from under a stationary pointer rather than the pointer moving
// off the element, and no leave fires. Expanding a decision row does exactly
// that, stranding an open quicklook on screen until something else happens
// to open one.
//
// Rather than enumerate the ways a leave can be missed, re-check the
// browser's own hover state on every pointer move and close as soon as the
// pointer is over neither the trigger nor the card. Keyboard use is exempt:
// a quicklook opened by focus stays put while focus remains inside it.
React.useEffect(() => {
if (!open) return;
// Slack around each box, so crossing the 4px gap between the trigger and
// the card does not read as leaving.
const EDGE_SLACK_PX = 12;
const nearBox = (element: Element | null, x: number, y: number) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
return (
x >= rect.left - EDGE_SLACK_PX
&& x <= rect.right + EDGE_SLACK_PX
&& y >= rect.top - EDGE_SLACK_PX
&& y <= rect.bottom + EDGE_SLACK_PX
);
};
const stillEngaged = (x: number, y: number) => {
const trigger = triggerRef.current;
const content = contentRef.current;
// Two independent signals, and closing needs both to agree the pointer is
// gone: `:hover` is authoritative about stacking and overlap, geometry
// survives the cases where hover state is not updated. Either one holding
// keeps the card open, so a real pointer resting on it is never dropped.
if (trigger?.matches(":hover") || content?.matches(":hover")) return true;
if (nearBox(trigger, x, y) || nearBox(content, x, y)) return true;
// Opened by keyboard: hold while focus is still inside.
const active = document.activeElement;
return trigger === active || (!!content && !!active && content.contains(active));
};
const onPointerMove = (event: PointerEvent) => {
if (!stillEngaged(event.clientX, event.clientY)) close();
};
document.addEventListener("pointermove", onPointerMove, { passive: true });
return () => document.removeEventListener("pointermove", onPointerMove);
}, [open, close]);
const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state;
const { data, isLoading } = useQuery({
...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }),
enabled: open,
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
});
const detailPath = createIssueDetailPath(issuePathId);
// 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<ReturnType<typeof setTimeout> | 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 = (
<RouterDom.Link
ref={setTriggerRef}
to={to}
state={prefetchedState}
className={className}
onMouseEnter={(event) => {
handleDwellPrefetch();
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
cancelHistoryPrefetch();
onMouseLeave?.(event);
}}
onPointerDown={(event) => {
handlePrefetch(event.pointerType !== "touch" && event.button === 0);
onPointerDown?.(event);
}}
onFocus={(event) => {
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(false);
onTouchStart?.(event);
}}
onClickCapture={(event) => {
handlePrefetch();
onClickCapture?.(event);
}}
onClick={(event) => {
close();
onClick?.(event);
}}
{...props}
>
{children}
</RouterDom.Link>
);
if (disableIssueQuicklook) {
return link;
}
return (
<Popover open={open} onOpenChange={(next) => (next ? openNow() : close())}>
<PopoverTrigger
asChild
// The link's own hover handler warms the task; history waits for a dwell.
onMouseEnter={scheduleOpen}
onMouseLeave={close}
>
{link}
</PopoverTrigger>
<PopoverContent
ref={contentRef}
// Opts into the scale-from-trigger motion defined in index.css.
data-quicklook
className={QUICKLOOK_CONTENT_CLASS}
side={issueQuicklookSide}
align={issueQuicklookAlign}
alignOffset={quicklookAlignOffset(issueQuicklookAlign)}
onMouseEnter={openNow}
onMouseLeave={close}
// A preview must not move focus in either direction. Opening already
// declined to take focus; closing has to decline to hand it back, or
// Radix focuses the trigger on the way out — and because this link
// opens the quicklook `onFocus`, that focus immediately reopens the
// card that was just dismissed. Hovering away then leaves it up
// permanently: close, refocus, reopen, forever.
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{data ? (
<IssueQuicklookCard issue={data} linkTo={detailPath} linkState={prefetchedState} compact />
) : (
<div className="space-y-2">
<div className="h-4 w-24 rounded bg-accent/50" />
<div className="h-4 w-full rounded bg-accent/40" />
<div className="h-4 w-3/4 rounded bg-accent/30" />
{!isLoading ? (
<p className="text-xs text-muted-foreground">Unable to load task preview.</p>
) : null}
</div>
)}
</PopoverContent>
</Popover>
);
});