mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
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 { 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<typeof import("@/lib/issueDetailCache")>();
|
|
return {
|
|
...actual,
|
|
prefetchIssueDetailForNavigation: (...args: Parameters<typeof actual.prefetchIssueDetailForNavigation>) => {
|
|
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: {
|
|
get: mockIssuesApiGet,
|
|
},
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|
return {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title: "Quicklook title",
|
|
description: "Quicklook description",
|
|
status: "todo",
|
|
priority: "medium",
|
|
reviewPolicy: null,
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
responsibleUserId: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: 1,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
labels: [],
|
|
labelIds: [],
|
|
myLastTouchAt: null,
|
|
lastExternalCommentAt: null,
|
|
isUnreadForMe: false,
|
|
workMode: "standard",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("IssueLinkQuicklook", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let queryClient: QueryClient;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
mockIssuesApiGet.mockResolvedValue(createIssue());
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
queryClient.clear();
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("navigation prefetch", () => {
|
|
function renderLink({ quicklook = false } = {}) {
|
|
navigationPrefetches.length = 0;
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<IssueLinkQuicklook issuePathId="PAP-1" issuePrefetch={createIssue()} to="/companies/company-1/issues/PAP-1" disableIssueQuicklook={!quicklook}>
|
|
PAP-1
|
|
</IssueLinkQuicklook>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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();
|
|
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<IssueLinkQuicklook
|
|
issuePathId="PAP-1"
|
|
issuePrefetch={issue}
|
|
to="/companies/company-1/issues/PAP-1"
|
|
>
|
|
PAP-1
|
|
</IssueLinkQuicklook>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
const trigger = container.querySelector("a") as HTMLAnchorElement | null;
|
|
expect(trigger).not.toBeNull();
|
|
|
|
act(() => {
|
|
trigger?.focus();
|
|
});
|
|
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
|
|
act(() => {
|
|
trigger?.blur();
|
|
});
|
|
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
|
|
act(() => {
|
|
vi.runOnlyPendingTimers();
|
|
});
|
|
|
|
expect(document.body.textContent).not.toContain("Quicklook title");
|
|
});
|
|
|
|
// Regression: the quicklook could only be closed by a `mouseleave` on the
|
|
// trigger or the card, and no leave event fires when the layout shifts the
|
|
// trigger out from under a stationary pointer — expanding a decision row does
|
|
// exactly that, stranding the card on screen. A pointer move anywhere clear of
|
|
// both boxes now closes it.
|
|
function renderQuicklook(issueOverrides: Partial<Issue> = {}) {
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<IssueLinkQuicklook
|
|
issuePathId="PAP-1"
|
|
issuePrefetch={createIssue(issueOverrides)}
|
|
to="/companies/company-1/issues/PAP-1"
|
|
>
|
|
PAP-1
|
|
</IssueLinkQuicklook>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
return container.querySelector("a") as HTMLAnchorElement;
|
|
}
|
|
|
|
function movePointerTo(x: number, y: number) {
|
|
act(() => {
|
|
document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true }));
|
|
});
|
|
}
|
|
|
|
it("closes an open quicklook once the pointer moves clear of the trigger and the card", () => {
|
|
const trigger = renderQuicklook();
|
|
|
|
act(() => {
|
|
trigger.focus();
|
|
});
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
|
|
// jsdom reports zero-size rects, so every box sits at the origin; a move far
|
|
// from it is unambiguously clear of both the trigger and the card.
|
|
act(() => {
|
|
trigger.blur();
|
|
});
|
|
movePointerTo(4000, 4000);
|
|
|
|
expect(document.body.textContent).not.toContain("Quicklook title");
|
|
});
|
|
|
|
// Regression: Radix returns focus to the trigger when a popover closes, and
|
|
// this link opens the quicklook `onFocus` — so dismissing it refocused the
|
|
// trigger, which reopened it, and hovering away left the card up for good.
|
|
it("does not reopen itself by taking focus back when it closes", () => {
|
|
const trigger = renderQuicklook();
|
|
|
|
act(() => {
|
|
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
vi.advanceTimersByTime(200);
|
|
});
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
|
|
act(() => {
|
|
trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }));
|
|
vi.runOnlyPendingTimers();
|
|
});
|
|
|
|
expect(document.activeElement).not.toBe(trigger);
|
|
expect(document.body.textContent).not.toContain("Quicklook title");
|
|
});
|
|
|
|
it("keeps a focus-opened quicklook up while focus stays on the trigger", () => {
|
|
const trigger = renderQuicklook();
|
|
|
|
act(() => {
|
|
trigger.focus();
|
|
});
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
|
|
// A keyboard user moving the mouse must not dismiss what focus opened.
|
|
movePointerTo(4000, 4000);
|
|
|
|
expect(document.body.textContent).toContain("Quicklook title");
|
|
});
|
|
|
|
// The card is the standard task preview for the whole app, so its rows and
|
|
// their order are the contract, not incidental markup.
|
|
function openCard(trigger: HTMLAnchorElement) {
|
|
act(() => {
|
|
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
vi.advanceTimersByTime(200);
|
|
});
|
|
return document.querySelector('[data-slot="popover-content"]');
|
|
}
|
|
|
|
it("states identity, then title, then summary", () => {
|
|
const card = openCard(
|
|
renderQuicklook({
|
|
status: "in_review",
|
|
// The card reads only `name` off the project; the rest of `Project` is
|
|
// irrelevant here, so this stands in for a full record.
|
|
project: { id: "project-1", name: "Paperclip App" } as unknown as Issue["project"],
|
|
}),
|
|
);
|
|
const text = card?.textContent ?? "";
|
|
|
|
expect(text).toContain("PAP-1");
|
|
expect(text).toContain("Paperclip App");
|
|
expect(text).toContain("Quicklook title");
|
|
expect(text).toContain("Quicklook description");
|
|
expect(text.indexOf("PAP-1")).toBeLessThan(text.indexOf("Quicklook title"));
|
|
expect(text.indexOf("Quicklook title")).toBeLessThan(text.indexOf("Quicklook description"));
|
|
});
|
|
|
|
// Status is the glyph, with no word of its own — so it must survive as the
|
|
// glyph's accessible name rather than as shape and colour alone.
|
|
it("carries the status on the glyph instead of spending a line on it", () => {
|
|
const card = openCard(renderQuicklook({ status: "in_review" }));
|
|
|
|
const glyph = card?.querySelector('[role="img"]');
|
|
expect(glyph?.getAttribute("aria-label")).toBe("In review");
|
|
|
|
// The status must reach a screen reader but occupy no visible text. Drop
|
|
// the glyph (whose <title> counts toward textContent) and the word is gone.
|
|
const withoutGlyph = card?.cloneNode(true) as HTMLElement;
|
|
withoutGlyph.querySelector('[role="img"]')?.remove();
|
|
expect(withoutGlyph.textContent).not.toContain("In review");
|
|
expect(withoutGlyph.textContent).not.toContain("in_review");
|
|
});
|
|
|
|
it("shows no separator or project when the task has no project", () => {
|
|
const card = openCard(renderQuicklook());
|
|
|
|
expect(card?.querySelector('[data-testid="quicklook-project"]')).toBeNull();
|
|
expect(card?.textContent).not.toContain("·");
|
|
});
|
|
|
|
// Radix aligns box to box, so the card's own padding would leave its text
|
|
// inset from the trigger's. The offset cancels the padding in whichever
|
|
// direction the card is aligned.
|
|
it("offsets the card by its padding so its text lines up with the trigger", () => {
|
|
// 12px of `p-3` padding plus the 1px border PopoverContent draws.
|
|
expect(quicklookAlignOffset("start")).toBe(-13);
|
|
expect(quicklookAlignOffset("end")).toBe(13);
|
|
expect(quicklookAlignOffset("center")).toBe(0);
|
|
expect(quicklookAlignOffset()).toBe(quicklookAlignOffset("start"));
|
|
// The offset only holds while the shell keeps that padding.
|
|
expect(QUICKLOOK_CONTENT_CLASS).toContain("p-3");
|
|
});
|
|
|
|
it("truncates a long project name but never the task key or the timestamp", () => {
|
|
const card = openCard(
|
|
renderQuicklook({
|
|
project: { id: "p1", name: "Really loooong project name" } as unknown as Issue["project"],
|
|
}),
|
|
);
|
|
|
|
const project = card?.querySelector('[data-testid="quicklook-project"]');
|
|
expect(project?.getAttribute("title")).toBe("Really loooong project name");
|
|
// The name is the only part allowed to give up width.
|
|
expect(project?.querySelector(".truncate")?.textContent).toBe("Really loooong project name");
|
|
expect(project?.getAttribute("class")).toContain("min-w-0");
|
|
|
|
const key = Array.from(card?.querySelectorAll("span") ?? []).find((s) => s.textContent === "PAP-1");
|
|
expect(key?.getAttribute("class")).toContain("shrink-0");
|
|
});
|
|
});
|