mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
perf(ui): open sidebar Recents tasks from cache (#20450)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI sidebar has a Recents list of the tasks a person opened
last
> - The Recents list renders from a local snapshot, but its links had no
warm-up. A click waited for the task-detail code and about nine
first-frame reads
> - Task lists (Inbox, Tasks) already warm a task on hover, press and
click, and #20355 added a batched `chat-bootstrap` read that had no UI
consumer
> - The hosted database is CPU-bound, so a fix must add at most one read
per page load and must not compete with the page's own reads
> - This pull request gives Recents the same open-intent prefetch as
task lists, warms the task-detail code, and preloads the top ten recent
tasks with one `chat-bootstrap` request after the page is idle
> - The benefit is that a click on a recent task shows its chat from
cache, without a wait for the API
## Linked Issues or Issue Description
Refs #20355 (server read this PR consumes). Builds on #20389, #20398 and
#20419.
**Is your feature request related to a problem?**
Opening a task from the sidebar's Recents was slower than opening it
from a task list. The Recents links had no prefetch. The task page
waited for its first-frame reads after the click.
**Describe the solution you'd like**
Recents links use the task lists' open-intent prefetch. When the page is
idle, one batched request loads the first frame of the most recent tasks
into the task page's caches.
**Describe alternatives you've considered**
Per-task prefetch of every recent task. That makes up to ten times nine
requests per page load, which the hosted database cannot afford.
**Additional context**
I searched open PRs and remote branches for "chat-bootstrap" and
"instant task opens". The only related work is the server-side branches
that merged as #20355. There is no other in-flight UI consumer of
`chat-bootstrap`.
## What Changed
- `ui/src/hooks/useIssueNavigationPrefetch.ts`: the open-intent handlers
from `IssueLinkQuicklook` move into one hook (light warm on pass-over;
`history: true` on a resting hover or focus, a mouse or pen press, and a
click). `IssueLinkQuicklook` now uses the hook. Its behavior does not
change.
- `ui/src/components/RecentTaskNavItem.tsx`: one Recents link wrapper
that applies the hook. The classic list (`SidebarRecentTasks`), the
Coordinator task rows and `DesignSidebar` use it. `SidebarNavItem`
accepts the handlers through a new optional `linkHandlers` prop.
- Classic Recents links now open `/issues/<identifier>` (Coordinator
already did). The old UUID link remounted the task page after a redirect
to the identifier route.
- `ui/src/hooks/useRecentTaskOpenWarmup.ts`: when Recents shows a task,
it fires the task-detail code warm-up (`useWarmTaskDetailCode`). After
two frames, a visible tab, no fetching queries and an idle callback, it
sends one `issues/chat-bootstrap?ids=` request for the ten most recent
tasks. It skips tasks whose first frame is cached and fresh. It sends no
request for an empty list or a hidden tab. It sends at most one request
per account per page load. Unmount or an account change aborts the
request.
- `ui/src/lib/issueChatBootstrapCache.ts`: `seedIssueChatBootstrap`
writes each field under the keys the task page reads, for the requested
ref, the id and the identifier: core issue, recent comments, comment
input facts (one batch, through the new `seedIssueIdBatch`), recent
activity, the recent run window (through the new shared
`buildRecentIssueRunWindow`), interactions, attachments, live runs and
active run. An explicit `null` active run is stored. A missing field is
not stored. It does not replace data that is as new or newer, data with
a read in flight, or data with a pending invalidation. Keys whose page
merges observed history (comments, activity, runs, input facts) are
filled only when empty. Items for another company are ignored.
- The seed fills the `?view=core` key, not the full detail key.
`seedIssueDetailCache` is not used: the core payload cannot stand in for
the detail read's related data, and seeding it there would suppress that
read.
- `issuesApi.chatBootstrap` client method.
- `tests/perf/collections/recent-task-open.spec.ts` and its README
section.
## Verification
Local disposable instance (`local_trusted`, production UI build,
embedded PostgreSQL, collection fixture plus 12 comments on each of 8
recent tasks, streamlined UI with the Coordinator sidebar). Journey:
open Projects with 8 Recents, wait for network idle plus 3 s, click a
recent task, measure until its latest comment is on screen. Each run
uses a fresh browser context (empty HTTP cache and storage apart from
Recents). Median of 5 runs, same server and fixture for both arms:
| Arm | Click to first chat frame | Idle page API requests |
| --- | --- | --- |
| `master` (b6f4008f9b), loopback | 227 ms (first run: 249 ms) | 24
(range 21–25) |
| This PR, loopback | 134 ms | 22 (range 21–27) |
| `master`, +150 ms on each API response | 455 ms | 19 |
| This PR, +150 ms on each API response | 135 ms | 20 (+1
`chat-bootstrap`) |
With added latency, the first frame no longer depends on API latency.
- `PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
recent-task-open.spec.ts recent-task-summaries.spec.ts`: 5 passed. On
`master` the two assertion cases fail (0 `chat-bootstrap` requests; 10
first-frame reads after the click).
- Reads that still follow a click on a preloaded task: full detail (`GET
/issues/:id`), `work-products`, `documents`, `feedback-votes`,
`tree-control/state`, `tree-holds`, `runner-goal`, `browsers`, and the
`read` mark. The `chat-bootstrap` payload does not cover them. None
gates the chat's first frame (the frame shows at 135 ms while every API
response waits 150 ms).
- Unit tests: `RecentTaskNavItem.test.tsx` (intent levels, href),
`useRecentTaskOpenWarmup.test.tsx` (one request for the top ten, code
warm-up, wait for page reads, hidden tab, fresh entries skipped, once
per account, user isolation, abort on unmount),
`issueChatBootstrapCache.test.ts` (each key in the endpoint shape,
`null` versus missing fields, company isolation, no downgrade of newer,
in-flight or invalidated data, merge keys filled only when empty).
- `ui` Vitest: `src/components src/hooks src/lib src/api` 694 files
passed; `src/pages src/App src/context` 188 files passed. `pnpm
check:token-gates`: clean. `pnpm verify` exits 2 because a `tests/` file
is a global CI input; required CI supplies the broad coverage.
## Risks
- Low to medium. One extra read per page load when Recents has tasks
without a fresh cached first frame. The read starts only after the
page's own queries settle, and the server reads at most two tasks at a
time.
- A live event that arrives while the preload is in flight, for a task
with no cached keys, can leave seeded data up to 30 s old until the next
live event or the next open after the stale time. Inbox hover prefetch
has the same window.
- Classic Recents links now use the identifier route instead of the UUID
route. The task page already redirected UUID links to this route.
- `tests/perf/collections` is a manual suite. CI does not run it.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, browser automation through Playwright).
Standard context window.
## 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>
This commit is contained in:
1 parent
c9226de30b
commit
7329eaf353
19 files changed
+1079
-43
No files matched your search
@@ -61,6 +61,20 @@ navigation start to the first Mine row (two frames after mount) over
|
||||
a native scenario without request interception and a separate diagnostic scenario that delays
|
||||
workspace reads by 3 seconds. The timings are attached evidence, not budgets.
|
||||
|
||||
`recent-task-open.spec.ts` covers opening a task from the sidebar's Recents.
|
||||
It gives eight fixture tasks twelve saved comments each and seeds them as the
|
||||
account's Recents. An idle Projects page must make exactly one
|
||||
`issues/chat-bootstrap` request (at most ten tasks) and no per-task reads.
|
||||
Clicking a preloaded task must show its latest comment without any of the
|
||||
chat's first-frame reads (core issue, recent comments, comment input facts,
|
||||
recent activity, recent runs, interactions, attachments, live and active run).
|
||||
The full detail read and the task page's other side reads still follow the
|
||||
first frame; the spec records them as an annotation. Its timing cases measure
|
||||
click to the latest comment on screen over at least 5 fresh browser contexts
|
||||
(empty HTTP cache and storage apart from Recents), natively and with 150 ms
|
||||
added to every API response. They also record the idle page's API request
|
||||
count. The timings are attached evidence, not budgets.
|
||||
|
||||
Run these focused browser checks before the full collection suite:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { collectionFixture, type CollectionFixture } from "./fixture";
|
||||
|
||||
// Opening a task from the sidebar's Recents. A page load with Recents makes at
|
||||
// most one batched chat-bootstrap read once the page is idle, and no per-task
|
||||
// first-frame reads. Clicking a preloaded task then shows its chat from cache.
|
||||
|
||||
const RECENT_COUNT = 8;
|
||||
const COMMENTS_PER_TASK = 12;
|
||||
const IDLE_SETTLE_MS = 3_000;
|
||||
|
||||
type RecentTask = { id: string; identifier: string; title: string; marker: string };
|
||||
let fixture: CollectionFixture;
|
||||
let recents: RecentTask[];
|
||||
|
||||
async function ensureRecentTasks(request: APIRequestContext): Promise<RecentTask[]> {
|
||||
const tasks: RecentTask[] = [];
|
||||
for (const id of fixture.taskIds.slice(0, RECENT_COUNT)) {
|
||||
const issueResponse = await request.get(`/api/issues/${id}?view=core`);
|
||||
expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy();
|
||||
const issue = await issueResponse.json() as { id: string; identifier: string; title: string };
|
||||
// No identifier in the marker: the chat would turn it into a task link.
|
||||
const marker = `Recent chat marker ${issue.id.slice(0, 8)}`;
|
||||
const recent = await (await request.get(`/api/issues/${id}/recent-comments`)).json() as { comments: Array<{ body: string }> };
|
||||
if (!recent.comments.some(comment => comment.body === marker)) {
|
||||
for (let index = recent.comments.length; index < COMMENTS_PER_TASK - 1; index += 1) {
|
||||
const created = await request.post(`/api/issues/${id}/comments`, { data: { body: `Saved context ${index} for ${issue.identifier}. `.repeat(8) } });
|
||||
expect(created.ok(), await created.text()).toBeTruthy();
|
||||
}
|
||||
const created = await request.post(`/api/issues/${id}/comments`, { data: { body: marker } });
|
||||
expect(created.ok(), await created.text()).toBeTruthy();
|
||||
}
|
||||
tasks.push({ id: issue.id, identifier: issue.identifier, title: issue.title, marker });
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function seedRecents(page: Page, request: APIRequestContext) {
|
||||
const session = await (await request.get("/api/auth/get-session")).json().catch(() => null);
|
||||
const userId: string | null = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
await page.addInitScript(({ companyId, userId, recents }) => {
|
||||
const entries = recents.map((task, index) => ({
|
||||
id: task.id, companyId, title: task.title, identifier: task.identifier, status: "backlog", recordedAt: Date.now() - index * 1000,
|
||||
}));
|
||||
for (const owner of new Set([userId ?? "__local_board__", "__local_board__"])) {
|
||||
localStorage.setItem(`paperclip.recentTasks.v2:${companyId}:${owner}`, JSON.stringify(entries));
|
||||
}
|
||||
}, { companyId: fixture.company.id, userId, recents });
|
||||
}
|
||||
|
||||
/** Per-task reads that gate the chat's first frame, plus the full detail read. */
|
||||
function classifyTaskRead(url: URL): string | null {
|
||||
const match = url.pathname.match(/^\/api\/issues\/([^/]+)(\/.*)?$/);
|
||||
if (!match) return null;
|
||||
const rest = match[2] ?? "";
|
||||
const view = url.searchParams.get("view");
|
||||
if (rest === "" || rest === "/") return view === "core" ? "core" : "detail";
|
||||
if (rest === "/recent-comments") return "recent-comments";
|
||||
if (rest === "/activity" && view === "recent") return "activity";
|
||||
if (rest === "/activity" && view === "comment-inputs") return "comment-inputs";
|
||||
if (rest === "/runs" && view === "recent") return "runs";
|
||||
if (rest === "/interactions") return "interactions";
|
||||
if (rest === "/attachments") return "attachments";
|
||||
if (rest === "/live-runs") return "live-runs";
|
||||
if (rest === "/active-run") return "active-run";
|
||||
if (rest === "/chat-bootstrap") return "chat-bootstrap-single";
|
||||
return `other:${rest}`;
|
||||
}
|
||||
const FIRST_FRAME_READS = new Set(["core", "recent-comments", "activity", "comment-inputs", "runs", "interactions", "attachments", "live-runs", "active-run"]);
|
||||
|
||||
function recordRequests(page: Page) {
|
||||
const requests: URL[] = [];
|
||||
page.on("request", item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname.startsWith("/api/")) requests.push(url);
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function loadIdle(page: Page) {
|
||||
await page.goto(`/${fixture.company.issuePrefix}/projects`);
|
||||
await expect(page.getByRole("link", { name: recents[0]!.title }).first()).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(IDLE_SETTLE_MS);
|
||||
}
|
||||
|
||||
/** Click a Recents link and return ms from the click to the latest saved comment being on screen. */
|
||||
async function openRecent(page: Page, task: RecentTask) {
|
||||
await page.evaluate((marker) => {
|
||||
const state = window as unknown as { __recentOpen?: { clickedAt?: number; shownAt?: number } };
|
||||
state.__recentOpen = {};
|
||||
document.addEventListener("pointerdown", () => { state.__recentOpen!.clickedAt ??= performance.now(); }, { capture: true, once: true });
|
||||
const check = () => {
|
||||
const visible = [...document.querySelectorAll("main *")].some(element =>
|
||||
element.childElementCount === 0 && element.textContent?.trim() === marker && (element as HTMLElement).offsetParent !== null);
|
||||
if (visible) {
|
||||
state.__recentOpen!.shownAt = performance.now();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
}, task.marker);
|
||||
await page.getByRole("link", { name: task.title }).first().click();
|
||||
await expect(page.getByText(task.marker, { exact: true }).first()).toBeVisible({ timeout: 20_000 });
|
||||
await page.waitForFunction(() => (window as unknown as { __recentOpen?: { shownAt?: number } }).__recentOpen?.shownAt !== undefined);
|
||||
return page.evaluate(() => {
|
||||
const state = (window as unknown as { __recentOpen: { clickedAt: number; shownAt: number } }).__recentOpen;
|
||||
return state.shownAt - state.clickedAt;
|
||||
});
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)]!;
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
fixture = await collectionFixture(request);
|
||||
recents = await ensureRecentTasks(request);
|
||||
});
|
||||
|
||||
test("an idle page with Recents makes one chat-bootstrap read and no per-task reads", async ({ page, request }) => {
|
||||
await seedRecents(page, request);
|
||||
const requests = recordRequests(page);
|
||||
await loadIdle(page);
|
||||
const bootstraps = requests.filter(url => url.pathname === `/api/companies/${fixture.company.id}/issues/chat-bootstrap`);
|
||||
expect(bootstraps).toHaveLength(1);
|
||||
const ids = bootstraps[0]!.searchParams.get("ids")!.split(",");
|
||||
expect(ids.length).toBeLessThanOrEqual(10);
|
||||
expect(ids).toContain(recents[0]!.id);
|
||||
expect(requests.map(classifyTaskRead).filter(Boolean)).toEqual([]);
|
||||
});
|
||||
|
||||
test("clicking a preloaded recent task shows its chat without first-frame reads", async ({ page, request }) => {
|
||||
await seedRecents(page, request);
|
||||
await loadIdle(page);
|
||||
const requests = recordRequests(page);
|
||||
const before = requests.length;
|
||||
const task = recents[1]!;
|
||||
await openRecent(page, task);
|
||||
const reads = requests.slice(before).map(classifyTaskRead).filter((kind): kind is string => !!kind);
|
||||
// The full detail read revalidates after the first frame; it is not a gate.
|
||||
test.info().annotations.push({ type: "after-click task reads", description: JSON.stringify(reads) });
|
||||
expect(reads.filter(kind => FIRST_FRAME_READS.has(kind))).toEqual([]);
|
||||
});
|
||||
|
||||
// Native loopback, then a diagnostic scenario that adds latency to every API
|
||||
// response, as a slow hosted database would. Timings are evidence, not budgets.
|
||||
for (const apiDelayMs of [0, 150]) {
|
||||
test(`timing: click on a recent task to first chat frame (+${apiDelayMs} ms API latency)`, async ({ browser, request, baseURL }) => {
|
||||
const runs = Math.max(5, Number(process.env.PAPERCLIP_COLLECTION_PERF_TIMING_RUNS ?? 5));
|
||||
const timings: number[] = [];
|
||||
const idleCounts: number[] = [];
|
||||
const afterClick: string[][] = [];
|
||||
for (let run = 0; run < runs; run += 1) {
|
||||
// A fresh context per run: empty HTTP cache, memory cache and storage,
|
||||
// except the seeded Recents history.
|
||||
// browser.newContext() does not inherit the project `use` options.
|
||||
const context = await browser.newContext({ baseURL, serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
if (apiDelayMs) {
|
||||
await page.route("**/api/**", async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, apiDelayMs));
|
||||
await route.fallback();
|
||||
});
|
||||
}
|
||||
await seedRecents(page, request);
|
||||
const requests = recordRequests(page);
|
||||
await loadIdle(page);
|
||||
idleCounts.push(requests.length);
|
||||
timings.push(await openRecent(page, recents[(run % (RECENT_COUNT - 1)) + 1]!));
|
||||
afterClick.push(requests.slice(idleCounts.at(-1)).map(classifyTaskRead).filter((kind): kind is string => !!kind));
|
||||
await context.close();
|
||||
}
|
||||
const summary = {
|
||||
apiDelayMs, runs, clickToFirstFrameMs: timings.map(Math.round), medianMs: Math.round(median(timings)),
|
||||
idleApiRequests: idleCounts, medianIdleApiRequests: median(idleCounts), taskReadsAfterClick: afterClick,
|
||||
};
|
||||
console.log(`recent-task-open timing ${JSON.stringify(summary)}`);
|
||||
await test.info().attach("recent-task-open-timing", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
|
||||
});
|
||||
}
|
||||
+16
-1
@@ -1,5 +1,7 @@
|
||||
import type { SetIssueTitle } from "@paperclipai/shared";
|
||||
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
|
||||
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS, ISSUE_SUMMARIES_MAX_IDS, type IssueChatBootstrap } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "./activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "./heartbeats";
|
||||
import type { IssueSummary } from "@paperclipai/shared";
|
||||
import type { ExecutionReconciliation } from "@paperclipai/shared";
|
||||
import type { IssueContextUsage } from "@paperclipai/shared";
|
||||
@@ -300,6 +302,19 @@ export const issuesApi = {
|
||||
detachInflightGet(path);
|
||||
return api.get<IssueSummary[]>(path, { ...options, cache: "no-store" });
|
||||
},
|
||||
/**
|
||||
* The task chat's first-frame reads for up to ten tasks in one request. Each
|
||||
* item's fields have the shape of the matching single-task endpoint;
|
||||
* missing and unreadable tasks are omitted.
|
||||
*/
|
||||
chatBootstrap: (companyId: string, issueRefs: readonly string[], options?: RequestOptions) => {
|
||||
const refs = [...new Set(issueRefs.map((ref) => ref.trim()).filter(Boolean))];
|
||||
if (refs.length === 0 || refs.length > ISSUE_CHAT_BOOTSTRAP_MAX_IDS) throw new Error("Chat bootstrap needs one to ten task references");
|
||||
const path = `/companies/${companyId}/issues/chat-bootstrap?${new URLSearchParams({ ids: refs.join(",") })}`;
|
||||
// Never join a request sent with the previous account's cookie.
|
||||
detachInflightGet(path);
|
||||
return api.get<IssueChatBootstrap<RunForIssue, LiveRunForIssue, ActiveRunForIssue>>(path, { ...options, cache: "no-store" });
|
||||
},
|
||||
/** Fetch every page for bounded task-detail relations, not just the default first page. */
|
||||
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset" | "sortField" | "sortDir" | "afterId" | "attention">, options?: RequestOptions): Promise<Issue[]> => {
|
||||
const pageSize = 500;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { issuesApi } from "@/api/issues";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
|
||||
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
@@ -15,6 +16,7 @@ import { Link, useLocation } from "@/lib/router";
|
||||
import { agentRouteRef, cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { RecentTaskNavItem } from "./RecentTaskNavItem";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
|
||||
import { useScrollbarWhileScrolling } from "./side-panel/use-scrollbar-while-scrolling";
|
||||
@@ -28,6 +30,8 @@ import { agentChatPinKey } from "./AgentChatPin";
|
||||
import { PinnedNavigationList, type PinnedNavigationItem } from "./PinnedNavigationList";
|
||||
import { companyProjectsQuery } from "@/lib/company-projects-query";
|
||||
|
||||
const NO_RECENT_TASKS: never[] = [];
|
||||
|
||||
const moreDestinations = [
|
||||
{ label: "Search", to: "/search", icon: Search },
|
||||
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||
@@ -119,6 +123,8 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
|
||||
const { entries: recentTasks } = useRecentTasks({ companyId, userId });
|
||||
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
// Warm opens only while the Recents list is on screen.
|
||||
useRecentTaskOpenWarmup({ companyId, userId, tasks: !rail && session.isSuccess ? recentTasks : NO_RECENT_TASKS });
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const location = useLocation();
|
||||
const attentionCount = useCoordinatorAttentionCount(companyId);
|
||||
@@ -193,7 +199,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
|
||||
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
|
||||
<RecentNavigationList>
|
||||
{recents.map(item => item.kind === "task"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
|
||||
? <RecentTaskNavItem className="design-sidebar-item" key={item.id} task={item.task} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
|
||||
: item.kind === "chat"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} iconNode={<AgentAvatar agent={item.agent} size={20} />} />
|
||||
: <ProjectRecent key={item.id} project={item.project} />)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAccountIdentity } from "@/api/companies-query";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
|
||||
import { DesignSidebar } from "./DesignSidebar";
|
||||
|
||||
/** Data adapter only; the visual subject is Components / Sidebar. */
|
||||
@@ -10,5 +11,6 @@ export function DesignSandboxSidebar() {
|
||||
const { userId } = useAccountIdentity();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { entries } = useRecentTasks({ companyId: selectedCompanyId, userId });
|
||||
useRecentTaskOpenWarmup({ companyId: selectedCompanyId, userId, tasks: entries });
|
||||
return <DesignSidebar recentTasks={entries} onNewTask={() => openNewIssue()} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { SidebarNavExpandedProvider, SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { RecentTaskNavItem, recentTaskPathRef } from "./RecentTaskNavItem";
|
||||
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
|
||||
import { Button } from "./ui/button";
|
||||
import { FlyoutAppearanceProvider } from "./ui/flyout";
|
||||
@@ -47,7 +48,7 @@ export function DesignSidebar({ onNewTask, recentTasks = [] }: { onNewTask: () =
|
||||
{companyRecentTasks.length > 0 ? <div className="design-sidebar-recents">
|
||||
<SidebarSection label="Recent Tasks">
|
||||
<div onScroll={handleRecentScroll} className="design-sidebar-recent-list scrollbar-while-scrolling" role="region" aria-label="Recent tasks" tabIndex={0}>
|
||||
{companyRecentTasks.map(issue => <SidebarNavItem key={issue.id} className="design-sidebar-item" to={`${root}/issues/${issue.identifier}`} label={issue.title} />)}
|
||||
{companyRecentTasks.map(issue => <RecentTaskNavItem key={issue.id} task={issue} className="design-sidebar-item" to={`${root}/issues/${recentTaskPathRef(issue)}`} label={issue.title} />)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</div> : null}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDet
|
||||
import {
|
||||
getIssueDetailQueryOptions,
|
||||
ISSUE_DETAIL_STALE_TIME_MS,
|
||||
prefetchIssueDetailForNavigation,
|
||||
} from "@/lib/issueDetailCache";
|
||||
import { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS, useIssueNavigationPrefetch } from "@/hooks/useIssueNavigationPrefetch";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -219,8 +219,7 @@ 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 { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS };
|
||||
|
||||
export const IssueLinkQuicklook = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
@@ -366,26 +365,7 @@ export const IssueLinkQuicklook = React.forwardRef<
|
||||
});
|
||||
|
||||
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 prefetchHandlers = useIssueNavigationPrefetch(issuePathId, issuePrefetch);
|
||||
const link = (
|
||||
<RouterDom.Link
|
||||
ref={setTriggerRef}
|
||||
@@ -393,34 +373,34 @@ export const IssueLinkQuicklook = React.forwardRef<
|
||||
state={prefetchedState}
|
||||
className={className}
|
||||
onMouseEnter={(event) => {
|
||||
handleDwellPrefetch();
|
||||
prefetchHandlers.onMouseEnter(event);
|
||||
onMouseEnter?.(event);
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
cancelHistoryPrefetch();
|
||||
prefetchHandlers.onMouseLeave(event);
|
||||
onMouseLeave?.(event);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePrefetch(event.pointerType !== "touch" && event.button === 0);
|
||||
prefetchHandlers.onPointerDown(event);
|
||||
onPointerDown?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
handleDwellPrefetch();
|
||||
prefetchHandlers.onFocus(event);
|
||||
openNow();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
cancelHistoryPrefetch();
|
||||
prefetchHandlers.onBlur(event);
|
||||
// Let clicks inside the portaled quicklook content finish before closing.
|
||||
setTimeout(() => close(), 0);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
handlePrefetch(false);
|
||||
prefetchHandlers.onTouchStart(event);
|
||||
onTouchStart?.(event);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
handlePrefetch();
|
||||
prefetchHandlers.onClickCapture(event);
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS } from "@/hooks/useIssueNavigationPrefetch";
|
||||
import { RecentTaskNavItem } from "./RecentTaskNavItem";
|
||||
|
||||
const prefetches = vi.hoisted(() => [] as Array<{ ref: string; history: boolean }>);
|
||||
const setSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/issueDetailCache", () => ({
|
||||
prefetchIssueDetailForNavigation: (_client: unknown, ref: string, options?: { history?: boolean }) => {
|
||||
prefetches.push({ ref, history: options?.history === true });
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
NavLink: ({ children, to, className, end: _end, state: _state, ...props }: {
|
||||
children: ReactNode; to: string; end?: boolean; state?: unknown;
|
||||
className?: string | ((state: { isActive: boolean }) => string);
|
||||
}) => (
|
||||
<a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ collapsed: false, peeking: false, isMobile: true, setSidebarOpen }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function renderItem(task: { id: string; identifier: string | null }, to?: string) {
|
||||
prefetches.length = 0;
|
||||
act(() => {
|
||||
root.render(<QueryClientProvider client={queryClient}><RecentTaskNavItem task={task} to={to} label="Recent task" /></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 })); });
|
||||
const press = (link: HTMLAnchorElement, pointerType: string) =>
|
||||
act(() => { link.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, pointerType })); });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
setSidebarOpen.mockClear();
|
||||
});
|
||||
|
||||
describe("RecentTaskNavItem", () => {
|
||||
it("opens and warms the identifier route, falling back to the id", () => {
|
||||
expect(renderItem({ id: "id-1", identifier: "PAP-1" }).getAttribute("href")).toBe("/issues/PAP-1");
|
||||
expect(renderItem({ id: "id-2", identifier: null }).getAttribute("href")).toBe("/issues/id-2");
|
||||
expect(renderItem({ id: "id-1", identifier: "PAP-1" }, "/PAP/issues/PAP-1").getAttribute("href")).toBe("/PAP/issues/PAP-1");
|
||||
});
|
||||
|
||||
it("warms only the light set when a pointer passes over, and the chat's first frame when it rests", () => {
|
||||
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
|
||||
hover(link, "mouseover");
|
||||
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS - 1); });
|
||||
hover(link, "mouseout");
|
||||
act(() => { vi.advanceTimersByTime(1_000); });
|
||||
expect(prefetches).toEqual([{ ref: "PAP-1", history: false }]);
|
||||
hover(link, "mouseover");
|
||||
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); });
|
||||
expect(prefetches.at(-1)).toEqual({ ref: "PAP-1", history: true });
|
||||
});
|
||||
|
||||
it("warms the first frame on a mouse press, keyboard focus that rests, and a click", () => {
|
||||
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
|
||||
press(link, "mouse");
|
||||
expect(prefetches).toEqual([{ ref: "PAP-1", history: true }]);
|
||||
prefetches.length = 0;
|
||||
act(() => { link.focus(); });
|
||||
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); });
|
||||
expect(prefetches).toEqual([{ ref: "PAP-1", history: false }, { ref: "PAP-1", history: true }]);
|
||||
prefetches.length = 0;
|
||||
act(() => { link.click(); });
|
||||
expect(prefetches).toEqual([{ ref: "PAP-1", history: true }]);
|
||||
// The sidebar's own click handling still runs.
|
||||
expect(setSidebarOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps a touch that may start a scroll to the light set", () => {
|
||||
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
|
||||
press(link, "touch");
|
||||
act(() => { link.dispatchEvent(new TouchEvent("touchstart", { bubbles: true })); });
|
||||
act(() => { vi.advanceTimersByTime(1_000); });
|
||||
expect(prefetches.length).toBeGreaterThan(0);
|
||||
expect(prefetches.every((prefetch) => !prefetch.history)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { useIssueNavigationPrefetch } from "@/hooks/useIssueNavigationPrefetch";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
/** The route reference a Recents link opens: the identifier when the task has one. */
|
||||
export function recentTaskPathRef(task: { id: string; identifier: string | null }) {
|
||||
return task.identifier ?? task.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Recents task link. It warms the task on open intent the same way task
|
||||
* lists do, under the reference the task page reads.
|
||||
*/
|
||||
export function RecentTaskNavItem({
|
||||
task,
|
||||
to,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof SidebarNavItem>, "to" | "linkHandlers"> & {
|
||||
task: { id: string; identifier: string | null };
|
||||
/** Defaults to the board-relative task path. */
|
||||
to?: string;
|
||||
}) {
|
||||
const pathRef = recentTaskPathRef(task);
|
||||
const linkHandlers = useIssueNavigationPrefetch(pathRef);
|
||||
return <SidebarNavItem {...props} to={to ?? `/issues/${pathRef}`} linkHandlers={linkHandlers} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import type { IssueNavigationPrefetchHandlers } from "@/hooks/useIssueNavigationPrefetch";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
@@ -66,6 +67,8 @@ interface SidebarNavItemProps {
|
||||
trailingLabel?: string;
|
||||
/** Rendered inside the right-aligned status cluster, before the live dot. */
|
||||
liveAccessory?: ReactNode;
|
||||
/** Extra link handlers, such as a task's open-intent prefetch. */
|
||||
linkHandlers?: Partial<IssueNavigationPrefetchHandlers>;
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
@@ -87,6 +90,7 @@ export function SidebarNavItem({
|
||||
trailing,
|
||||
trailingLabel,
|
||||
liveAccessory,
|
||||
linkHandlers,
|
||||
}: SidebarNavItemProps) {
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
// A contextual takeover forces full labels even when the saved global app
|
||||
@@ -120,6 +124,7 @@ export function SidebarNavItem({
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
end={end}
|
||||
aria-label={railAriaLabel}
|
||||
{...linkHandlers}
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
|
||||
@@ -24,6 +24,7 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
getTreeControlState: vi.fn(),
|
||||
createTreeHold: vi.fn(),
|
||||
releaseTreeHold: vi.fn(),
|
||||
chatBootstrap: vi.fn(async () => ({ items: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
@@ -158,7 +159,7 @@ describe("SidebarRecentTasks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const link = container.querySelector('a[href="/issues/issue-1"]');
|
||||
const link = container.querySelector('a[href="/issues/PAP-1"]');
|
||||
expect(mockIssuesApi.get).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
|
||||
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toBeUndefined();
|
||||
@@ -190,7 +191,8 @@ describe("SidebarRecentTasks", () => {
|
||||
mockIssuesApi.get.mockImplementation(async (id: string) => tasks.find((task) => task.id === id));
|
||||
const queryClient = await render();
|
||||
const order = () => Array.from(container.querySelectorAll("a")).map((link) => link.getAttribute("href"));
|
||||
const originalOrder = ["/issues/issue-3", "/issues/issue-2", "/issues/issue-1"];
|
||||
// Links open the identifier route the task page settles on.
|
||||
const originalOrder = ["/issues/PAP-3", "/issues/PAP-2", "/issues/PAP-1"];
|
||||
expect(order()).toEqual(originalOrder);
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -210,7 +212,7 @@ describe("SidebarRecentTasks", () => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(499); });
|
||||
expect(order()).toEqual(originalOrder);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
expect(order()).toEqual(["/issues/issue-2", "/issues/issue-1", "/issues/issue-3"]);
|
||||
expect(order()).toEqual(["/issues/PAP-2", "/issues/PAP-1", "/issues/PAP-3"]);
|
||||
expect(mockIssuesApi.get).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
|
||||
queryClient.clear();
|
||||
@@ -309,7 +311,7 @@ describe("SidebarRecentTasks", () => {
|
||||
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
|
||||
expect(mockIssuesApi.update).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Recent Tasks");
|
||||
expect(container.querySelector('a[href="/issues/issue-1"]')?.textContent).toContain(
|
||||
expect(container.querySelector('a[href="/issues/PAP-1"]')?.textContent).toContain(
|
||||
"Archive me",
|
||||
);
|
||||
expect(readRecentTasks(
|
||||
@@ -507,7 +509,7 @@ describe("SidebarRecentTasks", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('a[href="/issues/issue-2"]')?.textContent).toContain("Cross-tab task");
|
||||
expect(container.querySelector('a[href="/issues/PAP-2"]')?.textContent).toContain("Cross-tab task");
|
||||
});
|
||||
|
||||
it("preserves pending restart wake retries from before the snapshot storage migration", async () => {
|
||||
@@ -562,7 +564,7 @@ describe("SidebarRecentTasks", () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(container.querySelector('a[href="/issues/issue-hidden"]')).toBeNull();
|
||||
expect(container.querySelector('a[href="/issues/PAP-3"]')).toBeNull();
|
||||
expect(readRecentTasks(
|
||||
getRecentTasksStorageKey("company-1", "user-1"),
|
||||
"company-1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { agentsApi } from "@/api/agents";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
|
||||
import { useAccountIdentity } from "@/api/companies-query";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useOptionalToastActions } from "@/context/ToastContext";
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { RecentTaskNavItem } from "./RecentTaskNavItem";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const RECENT_TASK_MENU_ITEM_CLASS =
|
||||
@@ -111,6 +112,7 @@ function RecentTasksList({
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null);
|
||||
const restartRetryStorageKey = restartWakeRetryStorageKey(companyId, userId);
|
||||
useRecentTaskOpenWarmup({ companyId, userId, tasks: entries });
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -250,8 +252,8 @@ function RecentTasksList({
|
||||
<SidebarSection label="Recent Tasks">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
|
||||
<SidebarNavItem
|
||||
to={`/issues/${entry.id}`}
|
||||
<RecentTaskNavItem
|
||||
task={entry}
|
||||
label={entry.title}
|
||||
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon {...taskStatusIconProps(entry)} /> : undefined}
|
||||
trailing={entry.status === "in_review" && entry.externalConversationState === "waiting"
|
||||
|
||||
@@ -34,6 +34,26 @@ export function prefetchIssueIdBatches<T>(
|
||||
}))).then(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store one already-fetched batch under the keys `useIssueIdBatches` reads.
|
||||
* Only when no batch is cached for this key yet, so it never replaces or
|
||||
* splits batches a page has already read.
|
||||
*/
|
||||
export function seedIssueIdBatch<T>(
|
||||
client: QueryClient,
|
||||
key: readonly unknown[],
|
||||
ids: readonly string[],
|
||||
data: T[],
|
||||
updatedAt: number,
|
||||
) {
|
||||
if (!ids.length || ids.length > ISSUE_ID_BATCH_SIZE) return false;
|
||||
if (client.getQueryData<string[][]>([...key, "batch-ids"])?.length) return false;
|
||||
const batch = [...ids];
|
||||
client.setQueryData<string[][]>([...key, "batch-ids"], [batch]);
|
||||
client.setQueryData([...key, "batch", batch], data, { updatedAt });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useIssueIdBatches<T>(key: readonly unknown[], ids: string[], fetchBatch: (ids: string[]) => Promise<T[]>, enabled: boolean) {
|
||||
const client = useQueryClient();
|
||||
const serializedKey = JSON.stringify(key);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { FocusEvent, MouseEvent, PointerEvent, TouchEvent } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { prefetchIssueDetailForNavigation } from "@/lib/issueDetailCache";
|
||||
|
||||
/** 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 interface IssueNavigationPrefetchHandlers {
|
||||
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
onMouseLeave: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
onPointerDown: (event: PointerEvent<HTMLAnchorElement>) => void;
|
||||
onFocus: (event: FocusEvent<HTMLAnchorElement>) => void;
|
||||
onBlur: (event: FocusEvent<HTMLAnchorElement>) => void;
|
||||
onTouchStart: (event: TouchEvent<HTMLAnchorElement>) => void;
|
||||
onClickCapture: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link handlers that warm a task before it is opened.
|
||||
*
|
||||
* A pointer, a touch or keyboard focus that passes over a link 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.
|
||||
*/
|
||||
export function useIssueNavigationPrefetch(
|
||||
issuePathId: string,
|
||||
issue: Issue | null = null,
|
||||
): IssueNavigationPrefetchHandlers {
|
||||
const queryClient = useQueryClient();
|
||||
const historyPrefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelHistoryPrefetch = useCallback(() => {
|
||||
if (historyPrefetchTimerRef.current) clearTimeout(historyPrefetchTimerRef.current);
|
||||
historyPrefetchTimerRef.current = null;
|
||||
}, []);
|
||||
useEffect(() => cancelHistoryPrefetch, [cancelHistoryPrefetch]);
|
||||
const prefetch = useCallback((history = true) => {
|
||||
if (history) cancelHistoryPrefetch();
|
||||
void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue, history });
|
||||
}, [cancelHistoryPrefetch, issuePathId, issue, queryClient]);
|
||||
const dwell = useCallback(() => {
|
||||
prefetch(false);
|
||||
cancelHistoryPrefetch();
|
||||
historyPrefetchTimerRef.current = setTimeout(() => prefetch(true), ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS);
|
||||
}, [cancelHistoryPrefetch, prefetch]);
|
||||
return useMemo(() => ({
|
||||
onMouseEnter: () => dwell(),
|
||||
onMouseLeave: () => cancelHistoryPrefetch(),
|
||||
onPointerDown: (event) => prefetch(event.pointerType !== "touch" && event.button === 0),
|
||||
onFocus: () => dwell(),
|
||||
onBlur: () => cancelHistoryPrefetch(),
|
||||
onTouchStart: () => prefetch(false),
|
||||
onClickCapture: () => prefetch(),
|
||||
}), [cancelHistoryPrefetch, dwell, prefetch]);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TASK_COLLECTION_PAINTED_EVENT } from "@/lib/issue-detail-preload-events";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { seedIssueChatBootstrap, type IssueChatBootstrapEntry } from "@/lib/issueChatBootstrapCache";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { resetRecentTaskChatPreloadForTests, useRecentTaskOpenWarmup, type RecentTaskOpenWarmupTask } from "./useRecentTaskOpenWarmup";
|
||||
|
||||
const chatBootstrap = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { chatBootstrap } }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const tasks = (count: number): RecentTaskOpenWarmupTask[] =>
|
||||
Array.from({ length: count }, (_, index) => ({ id: `id-${index}`, identifier: `PAP-${index}` }));
|
||||
|
||||
function bootstrapItem(index: number, companyId = "company-1"): IssueChatBootstrapEntry {
|
||||
return {
|
||||
ref: `id-${index}`,
|
||||
issue: {
|
||||
id: `id-${index}`, identifier: `PAP-${index}`, companyId, title: `Task ${index}`, status: "todo", priority: "medium",
|
||||
workMode: "standard", projectId: null, parentId: null, description: null, assigneeAgentId: null, assigneeUserId: null,
|
||||
executionRunId: null, issueNumber: index, requestDepth: 0, createdAt: new Date(0), updatedAt: new Date(0),
|
||||
} as Issue,
|
||||
recentComments: { comments: [], hasMore: false, oldestCommentId: null, latestCommentId: null },
|
||||
commentInputFacts: [], recentActivity: [], recentRuns: [], interactions: [], attachments: [], liveRuns: [], activeRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Long enough for two frames and the idle fallback timer. */
|
||||
const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 400)); });
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let queryClient: QueryClient;
|
||||
let visibility: DocumentVisibilityState;
|
||||
|
||||
function Probe(props: { companyId: string | null; userId: string | null; tasks: RecentTaskOpenWarmupTask[] }) {
|
||||
useRecentTaskOpenWarmup(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
function render(props: { companyId?: string | null; userId?: string | null; tasks: RecentTaskOpenWarmupTask[] }) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Probe companyId={props.companyId === undefined ? "company-1" : props.companyId} userId={props.userId ?? "user-1"} tasks={props.tasks} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetRecentTaskChatPreloadForTests();
|
||||
chatBootstrap.mockReset();
|
||||
chatBootstrap.mockImplementation(async (_companyId: string, ids: string[]) => ({
|
||||
items: ids.map((id) => bootstrapItem(Number(id.slice(3)))),
|
||||
}));
|
||||
visibility = "visible";
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
describe("useRecentTaskOpenWarmup", () => {
|
||||
it("reads the ten most recent tasks in one request once the page settles, and seeds them", async () => {
|
||||
render({ tasks: tasks(12) });
|
||||
expect(chatBootstrap).not.toHaveBeenCalled();
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(chatBootstrap.mock.calls[0]![0]).toBe("company-1");
|
||||
expect(chatBootstrap.mock.calls[0]![1]).toEqual(tasks(10).map((task) => task.id));
|
||||
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-3"), "core"])).toMatchObject({ id: "id-3" });
|
||||
// Reordering or re-rendering Recents never starts a second request.
|
||||
render({ tasks: [...tasks(12)].reverse() });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("warms the task-detail code only when Recents has a task", async () => {
|
||||
const painted = vi.fn();
|
||||
window.addEventListener(TASK_COLLECTION_PAINTED_EVENT, painted);
|
||||
render({ tasks: [] });
|
||||
await settle();
|
||||
expect(painted).not.toHaveBeenCalled();
|
||||
expect(chatBootstrap).not.toHaveBeenCalled();
|
||||
render({ tasks: tasks(1) });
|
||||
await settle();
|
||||
expect(painted).toHaveBeenCalledTimes(1);
|
||||
window.removeEventListener(TASK_COLLECTION_PAINTED_EVENT, painted);
|
||||
});
|
||||
|
||||
it("waits for the page's own reads to finish", async () => {
|
||||
let finish!: (value: string) => void;
|
||||
void queryClient.fetchQuery({ queryKey: ["page-read"], queryFn: () => new Promise<string>((resolve) => { finish = resolve; }) });
|
||||
render({ tasks: tasks(2) });
|
||||
await settle();
|
||||
expect(chatBootstrap).not.toHaveBeenCalled();
|
||||
await act(async () => { finish("done"); });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("makes no request while the tab is hidden", async () => {
|
||||
visibility = "hidden";
|
||||
render({ tasks: tasks(2) });
|
||||
await settle();
|
||||
expect(chatBootstrap).not.toHaveBeenCalled();
|
||||
visibility = "visible";
|
||||
act(() => { document.dispatchEvent(new Event("visibilitychange")); });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips tasks whose first frame is cached and fresh, and makes no request when none is left", async () => {
|
||||
// A full first frame for two tasks (as an earlier open or preload leaves it)...
|
||||
seedIssueChatBootstrap(queryClient, bootstrapItem(0), { companyId: "company-1", requestedAt: Date.now() });
|
||||
seedIssueChatBootstrap(queryClient, bootstrapItem(1), { companyId: "company-1", requestedAt: Date.now() });
|
||||
// ...and a light warm (a brief hover) for the third, which is not a first frame.
|
||||
queryClient.setQueryData([...queryKeys.issues.detail("PAP-2"), "core"], bootstrapItem(2).issue);
|
||||
queryClient.setQueryData([...queryKeys.issues.comments("PAP-2"), "recent"], bootstrapItem(2).recentComments);
|
||||
render({ tasks: tasks(3) });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(chatBootstrap.mock.calls[0]![1]).toEqual(["id-2"]);
|
||||
|
||||
// Now all three are cached in full: no request at all.
|
||||
resetRecentTaskChatPreloadForTests();
|
||||
chatBootstrap.mockClear();
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
render({ tasks: tasks(3) });
|
||||
await settle();
|
||||
expect(chatBootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preloads once per account per page load and never seeds another company's tasks", async () => {
|
||||
render({ tasks: tasks(1) });
|
||||
await settle();
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
render({ tasks: tasks(1) });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Another person in the same browser is their own scope.
|
||||
chatBootstrap.mockImplementationOnce(async () => ({ items: [bootstrapItem(5, "company-2")] }));
|
||||
render({ userId: "user-2", tasks: tasks(6).slice(5) });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(2);
|
||||
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-5"), "core"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the answer when the sidebar unmounts before it arrives", async () => {
|
||||
let answer!: (value: unknown) => void;
|
||||
chatBootstrap.mockImplementationOnce((_companyId: string, _ids: string[], options: { signal: AbortSignal }) => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
expect(options.signal.aborted).toBe(false);
|
||||
}));
|
||||
render({ tasks: tasks(1) });
|
||||
await settle();
|
||||
expect(chatBootstrap).toHaveBeenCalledTimes(1);
|
||||
const signal = (chatBootstrap.mock.calls[0]![2] as { signal: AbortSignal }).signal;
|
||||
act(() => root.unmount());
|
||||
expect(signal.aborted).toBe(true);
|
||||
await act(async () => { answer({ items: [bootstrapItem(0)] }); });
|
||||
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-0"), "core"])).toBeUndefined();
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { hasFreshIssueChatFirstFrame, seedIssueChatBootstrap } from "@/lib/issueChatBootstrapCache";
|
||||
import { useWarmTaskDetailCode } from "./useWarmTaskDetailCode";
|
||||
|
||||
export type RecentTaskOpenWarmupTask = { id: string; identifier: string | null };
|
||||
|
||||
/** Accounts (company and user) whose Recents were preloaded during this page load. */
|
||||
const preloadedScopes = new Set<string>();
|
||||
|
||||
export function resetRecentTaskChatPreloadForTests() {
|
||||
preloadedScopes.clear();
|
||||
}
|
||||
|
||||
const IDLE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Run `start` once the page has painted, the tab is visible, no query is
|
||||
* fetching (the page's own reads have finished) and the browser is idle.
|
||||
* Returns a cancel function.
|
||||
*/
|
||||
function whenPageSettles(queryClient: QueryClient, start: () => void) {
|
||||
const cleanups = new Set<() => void>();
|
||||
const add = (cleanup: () => void) => cleanups.add(cleanup);
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
done = true;
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
cleanups.clear();
|
||||
};
|
||||
const step = () => {
|
||||
if (done) return;
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
cleanups.clear();
|
||||
if (document.visibilityState !== "visible") {
|
||||
const onVisible = () => { if (document.visibilityState === "visible") step(); };
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
add(() => document.removeEventListener("visibilitychange", onVisible));
|
||||
return;
|
||||
}
|
||||
if (queryClient.isFetching() > 0) {
|
||||
add(queryClient.getQueryCache().subscribe(() => {
|
||||
if (queryClient.isFetching() === 0) step();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const run = () => {
|
||||
if (done) return;
|
||||
// Something started while waiting for idle time: wait for it as well.
|
||||
if (document.visibilityState !== "visible" || queryClient.isFetching() > 0) {
|
||||
step();
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
cleanups.clear();
|
||||
start();
|
||||
};
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const handle = window.requestIdleCallback(run, { timeout: IDLE_TIMEOUT_MS });
|
||||
add(() => window.cancelIdleCallback(handle));
|
||||
} else {
|
||||
const handle = window.setTimeout(run, 200);
|
||||
add(() => window.clearTimeout(handle));
|
||||
}
|
||||
};
|
||||
// Two frames: the page's primary content has painted.
|
||||
let second: number | undefined;
|
||||
const first = window.requestAnimationFrame(() => { second = window.requestAnimationFrame(step); });
|
||||
add(() => {
|
||||
window.cancelAnimationFrame(first);
|
||||
if (second !== undefined) window.cancelAnimationFrame(second);
|
||||
});
|
||||
return cancel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make opening a recent task from the sidebar nearly instant.
|
||||
*
|
||||
* Once Recents has painted, warm the task-detail code as task lists do. Then,
|
||||
* once the page is idle, read the chat's first frame for the most recent tasks
|
||||
* in one `chat-bootstrap` request and seed it into the task page's caches.
|
||||
* At most one request per account per page load; none for an empty list, a
|
||||
* hidden tab or tasks whose first frame is already cached and fresh. Live
|
||||
* updates keep seeded entries fresh afterwards, one task at a time.
|
||||
*/
|
||||
export function useRecentTaskOpenWarmup({
|
||||
companyId,
|
||||
userId,
|
||||
tasks,
|
||||
}: {
|
||||
companyId: string | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
tasks: readonly RecentTaskOpenWarmupTask[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const hasTasks = tasks.length > 0;
|
||||
useWarmTaskDetailCode(hasTasks);
|
||||
// Read when the preload starts. A reorder must not cancel a request in flight.
|
||||
const latestTasks = useRef(tasks);
|
||||
latestTasks.current = tasks;
|
||||
useEffect(() => {
|
||||
if (!companyId || !hasTasks || typeof window.requestAnimationFrame !== "function") return;
|
||||
const scope = `${companyId}:${userId ?? "__local_board__"}`;
|
||||
if (preloadedScopes.has(scope)) return;
|
||||
const controller = new AbortController();
|
||||
const cancel = whenPageSettles(queryClient, () => {
|
||||
if (preloadedScopes.has(scope)) return;
|
||||
preloadedScopes.add(scope);
|
||||
const ids = latestTasks.current
|
||||
.slice(0, ISSUE_CHAT_BOOTSTRAP_MAX_IDS)
|
||||
.filter((task) => !hasFreshIssueChatFirstFrame(queryClient, task.identifier ? [task.id, task.identifier] : [task.id]))
|
||||
.map((task) => task.id);
|
||||
if (!ids.length) return;
|
||||
const requestedAt = Date.now();
|
||||
// A synchronous throw (bad input) is also only a missed preload.
|
||||
Promise.resolve().then(() => issuesApi.chatBootstrap(companyId, ids, { signal: controller.signal })).then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
for (const item of response.items) seedIssueChatBootstrap(queryClient, item, { companyId, requestedAt });
|
||||
}).catch(() => {
|
||||
// Best effort: a task opened later reads its own first frame.
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancel();
|
||||
controller.abort();
|
||||
};
|
||||
}, [companyId, hasTasks, queryClient, userId]);
|
||||
}
|
||||
@@ -81,6 +81,18 @@ export async function fetchRecentIssueRunWindow(
|
||||
const existingRunIds = new Set(recent.map((run) => run.runId));
|
||||
previous = previous.filter((run) => existingRunIds.has(run.runId));
|
||||
}
|
||||
return buildRecentIssueRunWindow(recent, previous, !reconciledFullHistory && previousWindow?.hasEarlierRuns === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The run window for a `/runs?view=recent` answer. `previous` holds runs
|
||||
* already observed while the task is open; a cold open passes none.
|
||||
*/
|
||||
export function buildRecentIssueRunWindow(
|
||||
recent: RunForIssue[],
|
||||
previous: RunForIssue[] = [],
|
||||
previouslyHadEarlierRuns = false,
|
||||
): RecentIssueRunWindow {
|
||||
// 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)]);
|
||||
@@ -89,7 +101,7 @@ export async function fetchRecentIssueRunWindow(
|
||||
// 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),
|
||||
|| (recent.length >= 2 && previouslyHadEarlierRuns),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { ActivityEvent, Issue, IssueComment } from "@paperclipai/shared";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { RunForIssue } from "@/api/activity";
|
||||
import { hasFreshIssueChatFirstFrame, seedIssueChatBootstrap, type IssueChatBootstrapEntry } from "./issueChatBootstrapCache";
|
||||
import {
|
||||
issueCommentInputFactsQueryKey,
|
||||
recentIssueActivityQueryKey,
|
||||
recentIssueRunWindowQueryKey,
|
||||
} from "./issue-chat-history-queries";
|
||||
import { getCachedIssueDetail } from "./issueDetailCache";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
const ID = "11111111-1111-4111-8111-111111111111";
|
||||
const IDENTIFIER = "PAP-7";
|
||||
const REQUESTED_AT = 1_000_000;
|
||||
|
||||
function issue(overrides: Partial<Issue> = {}) {
|
||||
return {
|
||||
id: ID, identifier: IDENTIFIER, companyId: "company-1", title: "Recent task", status: "todo", priority: "medium",
|
||||
workMode: "standard", projectId: null, parentId: null, description: null, assigneeAgentId: null,
|
||||
assigneeUserId: null, executionRunId: null, issueNumber: 7, requestDepth: 0,
|
||||
createdAt: new Date(0), updatedAt: new Date(0),
|
||||
...overrides,
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
const comment = (id: string) => ({ id, issueId: ID, body: id, createdAt: new Date(0) }) as unknown as IssueComment;
|
||||
const event = (id: string) => ({ id, action: "comment.created" }) as unknown as ActivityEvent;
|
||||
const run = (runId: string) => ({ runId, status: "succeeded" }) as unknown as RunForIssue;
|
||||
|
||||
function item(overrides: Partial<IssueChatBootstrapEntry> = {}): IssueChatBootstrapEntry {
|
||||
return {
|
||||
ref: ID,
|
||||
issue: issue(),
|
||||
recentComments: { comments: [comment("c2"), comment("c1")], hasMore: true, oldestCommentId: "c1", latestCommentId: "c2" },
|
||||
commentInputFacts: [event("fact-1")],
|
||||
recentActivity: [event("a1"), event("a2"), event("a1")],
|
||||
recentRuns: [run("r2"), run("r1")],
|
||||
interactions: [],
|
||||
attachments: [],
|
||||
liveRuns: [],
|
||||
activeRun: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const coreKey = (ref: string) => [...queryKeys.issues.detail(ref), "core"];
|
||||
const commentsKey = (ref: string) => [...queryKeys.issues.comments(ref), "recent"];
|
||||
|
||||
let queryClient: QueryClient;
|
||||
function client() {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return queryClient;
|
||||
}
|
||||
afterEach(() => queryClient?.clear());
|
||||
|
||||
describe("seedIssueChatBootstrap", () => {
|
||||
it("fills every first-frame key in the shape of its single endpoint, under id and identifier", () => {
|
||||
const qc = client();
|
||||
const entry = item();
|
||||
expect(seedIssueChatBootstrap(qc, entry, { companyId: "company-1", requestedAt: REQUESTED_AT })).toBe(true);
|
||||
for (const ref of [ID, IDENTIFIER]) {
|
||||
expect(qc.getQueryData(coreKey(ref))).toBe(entry.issue);
|
||||
expect(qc.getQueryData(commentsKey(ref))).toBe(entry.recentComments);
|
||||
// The activity key holds the de-duplicated recent answer.
|
||||
expect(qc.getQueryData(recentIssueActivityQueryKey(ref))).toEqual([event("a1"), event("a2")]);
|
||||
// A cold open shows the latest run and discloses the earlier one.
|
||||
expect(qc.getQueryData(recentIssueRunWindowQueryKey(ref))).toEqual({ runs: [run("r2")], hasEarlierRuns: true });
|
||||
expect(qc.getQueryData(queryKeys.issues.interactions(ref))).toEqual([]);
|
||||
expect(qc.getQueryData(queryKeys.issues.attachments(ref))).toEqual([]);
|
||||
expect(qc.getQueryData(queryKeys.issues.liveRuns(ref))).toEqual([]);
|
||||
// The input facts are stored as the single batch the chat requests: the sorted saved comment ids.
|
||||
const factsKey = issueCommentInputFactsQueryKey(ref);
|
||||
expect(qc.getQueryData([...factsKey, "batch-ids"])).toEqual([["c1", "c2"]]);
|
||||
expect(qc.getQueryData([...factsKey, "batch", ["c1", "c2"]])).toEqual([event("fact-1")]);
|
||||
expect(qc.getQueryState(coreKey(ref))?.dataUpdatedAt).toBe(REQUESTED_AT);
|
||||
}
|
||||
// Core cannot stand in for the full detail read and its related data.
|
||||
expect(qc.getQueryData(queryKeys.issues.detail(ID))).toBeUndefined();
|
||||
expect(getCachedIssueDetail(qc, IDENTIFIER)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores an explicit null active run but leaves missing fields unset", () => {
|
||||
const qc = client();
|
||||
const entry = item({ activeRun: null });
|
||||
delete (entry as Partial<IssueChatBootstrapEntry>).liveRuns;
|
||||
delete (entry as Partial<IssueChatBootstrapEntry>).commentInputFacts;
|
||||
seedIssueChatBootstrap(qc, entry, { companyId: "company-1", requestedAt: REQUESTED_AT });
|
||||
expect(qc.getQueryState(queryKeys.issues.activeRun(ID))?.data).toBeNull();
|
||||
expect(qc.getQueryState(queryKeys.issues.activeRun(ID))?.status).toBe("success");
|
||||
expect(qc.getQueryState(queryKeys.issues.liveRuns(ID))).toBeUndefined();
|
||||
expect(qc.getQueryState([...issueCommentInputFactsQueryKey(ID), "batch-ids"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores an item for another company", () => {
|
||||
const qc = client();
|
||||
expect(seedIssueChatBootstrap(qc, item({ issue: issue({ companyId: "company-2" }) }), { companyId: "company-1", requestedAt: REQUESTED_AT })).toBe(false);
|
||||
expect(qc.getQueryCache().getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never replaces cached data that is as new or newer, or a key with a read in flight or pending invalidation", () => {
|
||||
const qc = client();
|
||||
const newer = issue({ title: "Newer title" });
|
||||
qc.setQueryData(coreKey(ID), newer, { updatedAt: REQUESTED_AT + 1 });
|
||||
qc.setQueryData(queryKeys.issues.interactions(ID), ["stale"], { updatedAt: REQUESTED_AT - 1 });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.issues.interactions(ID) });
|
||||
qc.setQueryData(queryKeys.issues.attachments(ID), ["older"], { updatedAt: REQUESTED_AT - 1 });
|
||||
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
|
||||
expect(qc.getQueryData(coreKey(ID))).toBe(newer);
|
||||
expect(qc.getQueryData(queryKeys.issues.interactions(ID))).toEqual(["stale"]);
|
||||
// A whole-answer endpoint with older, valid data takes the newer answer.
|
||||
expect(qc.getQueryData(queryKeys.issues.attachments(ID))).toEqual([]);
|
||||
});
|
||||
|
||||
it("only fills empty keys whose page merges observed history", () => {
|
||||
const qc = client();
|
||||
const observed = { comments: [comment("c3")], hasMore: false, oldestCommentId: "c3", latestCommentId: "c3" };
|
||||
qc.setQueryData(commentsKey(ID), observed, { updatedAt: REQUESTED_AT - 10_000 });
|
||||
qc.setQueryData(recentIssueActivityQueryKey(ID), [event("seen")], { updatedAt: REQUESTED_AT - 10_000 });
|
||||
qc.setQueryData(recentIssueRunWindowQueryKey(ID), { runs: [run("r9")], hasEarlierRuns: false }, { updatedAt: REQUESTED_AT - 10_000 });
|
||||
qc.setQueryData([...issueCommentInputFactsQueryKey(ID), "batch-ids"], [["c3"]]);
|
||||
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
|
||||
expect(qc.getQueryData(commentsKey(ID))).toBe(observed);
|
||||
expect(qc.getQueryData(recentIssueActivityQueryKey(ID))).toEqual([event("seen")]);
|
||||
expect(qc.getQueryData(recentIssueRunWindowQueryKey(ID))).toEqual({ runs: [run("r9")], hasEarlierRuns: false });
|
||||
expect(qc.getQueryData([...issueCommentInputFactsQueryKey(ID), "batch-ids"])).toEqual([["c3"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFreshIssueChatFirstFrame", () => {
|
||||
it("is true only while every first-frame read is cached, valid and fresh", () => {
|
||||
const qc = client();
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [ID], REQUESTED_AT)).toBe(false);
|
||||
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 29_999)).toBe(true);
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 30_000)).toBe(false);
|
||||
qc.invalidateQueries({ queryKey: commentsKey(IDENTIFIER) });
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false after a light warm that cached only the core issue and recent comments", () => {
|
||||
const qc = client();
|
||||
const entry = item();
|
||||
qc.setQueryData(coreKey(IDENTIFIER), entry.issue, { updatedAt: REQUESTED_AT });
|
||||
qc.setQueryData(commentsKey(IDENTIFIER), entry.recentComments, { updatedAt: REQUESTED_AT });
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [ID, IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false while a recent comment's input facts are missing", () => {
|
||||
const qc = client();
|
||||
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
|
||||
const factsKey = issueCommentInputFactsQueryKey(IDENTIFIER);
|
||||
qc.removeQueries({ queryKey: [...factsKey, "batch", ["c1", "c2"]], exact: true });
|
||||
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { IssueChatBootstrap, IssueRecentComments } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "@/api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "@/api/heartbeats";
|
||||
import { seedIssueIdBatch } from "@/hooks/useIssueIdBatches";
|
||||
import { seedIssueDetailProjections } from "./issueDetailCache";
|
||||
import {
|
||||
buildRecentIssueRunWindow,
|
||||
issueCommentInputFactsQueryKey,
|
||||
issueInputCommentIds,
|
||||
recentIssueActivityQueryKey,
|
||||
recentIssueRunWindowQueryKey,
|
||||
} from "./issue-chat-history-queries";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export type IssueChatBootstrapResponse = IssueChatBootstrap<RunForIssue, LiveRunForIssue, ActiveRunForIssue>;
|
||||
export type IssueChatBootstrapEntry = IssueChatBootstrapResponse["items"][number];
|
||||
|
||||
/** How long seeded first-frame data counts as fresh: the app's query stale time. */
|
||||
export const ISSUE_CHAT_BOOTSTRAP_FRESH_MS = 30_000;
|
||||
|
||||
/** The reference the caller asked for, plus the task's id and identifier. */
|
||||
function bootstrapRefs(item: Pick<IssueChatBootstrapEntry, "ref" | "issue">) {
|
||||
return [...new Set([item.ref, item.issue.id, item.issue.identifier].filter((ref): ref is string => !!ref))];
|
||||
}
|
||||
|
||||
function coreKey(ref: string): QueryKey {
|
||||
return [...queryKeys.issues.detail(ref), "core"];
|
||||
}
|
||||
|
||||
function recentCommentsKey(ref: string): QueryKey {
|
||||
return [...queryKeys.issues.comments(ref), "recent"];
|
||||
}
|
||||
|
||||
function isFresh(queryClient: QueryClient, key: QueryKey, now: number) {
|
||||
const state = queryClient.getQueryState(key);
|
||||
return state?.data !== undefined && !state.isInvalidated && now - state.dataUpdatedAt < ISSUE_CHAT_BOOTSTRAP_FRESH_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every read that gates a task's first chat frame is cached and
|
||||
* fresh under one of its refs. A light warm (core issue and recent comments
|
||||
* only) is not a first frame: the task still needs the preload.
|
||||
*/
|
||||
export function hasFreshIssueChatFirstFrame(queryClient: QueryClient, refs: readonly string[], now = Date.now()) {
|
||||
return refs.some((ref) => {
|
||||
const keys = [
|
||||
coreKey(ref),
|
||||
recentCommentsKey(ref),
|
||||
recentIssueActivityQueryKey(ref),
|
||||
recentIssueRunWindowQueryKey(ref),
|
||||
queryKeys.issues.interactions(ref),
|
||||
queryKeys.issues.attachments(ref),
|
||||
queryKeys.issues.liveRuns(ref),
|
||||
];
|
||||
if (!keys.every((key) => isFresh(queryClient, key, now))) return false;
|
||||
// The input facts of every saved recent comment, in the batches the chat reads.
|
||||
const comments = queryClient.getQueryData<IssueRecentComments>(recentCommentsKey(ref))?.comments ?? [];
|
||||
const factsKey = issueCommentInputFactsQueryKey(ref);
|
||||
const batches = queryClient.getQueryData<string[][]>([...factsKey, "batch-ids"]) ?? [];
|
||||
return issueInputCommentIds(comments).every((id) => {
|
||||
const batch = batches.find((ids) => ids.includes(id));
|
||||
return !!batch && isFresh(queryClient, [...factsKey, "batch", batch], now);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `data` under `key` unless the cache holds something at least as new.
|
||||
*
|
||||
* A key with a read in flight or a pending invalidation is left to that read.
|
||||
* `replace` endpoints return the whole answer, so older cached data may be
|
||||
* replaced. The others merge what the open page observed (comments, activity,
|
||||
* runs), so only an empty key is filled.
|
||||
*/
|
||||
function seedKey(queryClient: QueryClient, key: QueryKey, data: unknown, requestedAt: number, replace: boolean) {
|
||||
// A missing field (an older server, or a partial answer) is not an empty answer.
|
||||
if (data === undefined) return false;
|
||||
const state = queryClient.getQueryState(key);
|
||||
if (state) {
|
||||
if (state.fetchStatus === "fetching" || state.isInvalidated) return false;
|
||||
if (state.data !== undefined && (!replace || state.dataUpdatedAt >= requestedAt)) return false;
|
||||
}
|
||||
// Stamped with the request time, not the arrival time, so staleness counts
|
||||
// from when the server could last have read the task.
|
||||
queryClient.setQueryData(key, data, { updatedAt: requestedAt });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one chat-bootstrap item into the query keys the task page reads before
|
||||
* its first frame, under every reference the task can be opened by.
|
||||
*
|
||||
* The item's `issue` is the `?view=core` payload, so it fills the core key and
|
||||
* its projections, never the full detail key: core cannot stand in for the
|
||||
* detail read's related data. Items for another company are ignored.
|
||||
*/
|
||||
export function seedIssueChatBootstrap(
|
||||
queryClient: QueryClient,
|
||||
item: IssueChatBootstrapEntry,
|
||||
options: { companyId: string; requestedAt: number },
|
||||
) {
|
||||
const { companyId, requestedAt } = options;
|
||||
if (!item?.issue || item.issue.companyId !== companyId) return false;
|
||||
const inputCommentIds = item.recentComments ? issueInputCommentIds(item.recentComments.comments) : [];
|
||||
for (const ref of bootstrapRefs(item)) {
|
||||
seedKey(queryClient, coreKey(ref), item.issue, requestedAt, true);
|
||||
seedKey(queryClient, recentCommentsKey(ref), item.recentComments, requestedAt, false);
|
||||
if (item.commentInputFacts !== undefined) {
|
||||
seedIssueIdBatch(queryClient, issueCommentInputFactsQueryKey(ref), inputCommentIds, item.commentInputFacts, requestedAt);
|
||||
}
|
||||
seedKey(
|
||||
queryClient,
|
||||
recentIssueActivityQueryKey(ref),
|
||||
item.recentActivity && [...new Map(item.recentActivity.map((event) => [event.id, event])).values()],
|
||||
requestedAt,
|
||||
false,
|
||||
);
|
||||
seedKey(
|
||||
queryClient,
|
||||
recentIssueRunWindowQueryKey(ref),
|
||||
item.recentRuns && buildRecentIssueRunWindow(item.recentRuns),
|
||||
requestedAt,
|
||||
false,
|
||||
);
|
||||
seedKey(queryClient, queryKeys.issues.interactions(ref), item.interactions, requestedAt, true);
|
||||
seedKey(queryClient, queryKeys.issues.attachments(ref), item.attachments, requestedAt, true);
|
||||
seedKey(queryClient, queryKeys.issues.liveRuns(ref), item.liveRuns, requestedAt, true);
|
||||
// `null` is an answer: the task has no active run.
|
||||
seedKey(queryClient, queryKeys.issues.activeRun(ref), item.activeRun, requestedAt, true);
|
||||
}
|
||||
// Plan document and work-product projections, filled only where empty.
|
||||
seedIssueDetailProjections(queryClient, item.issue, item.ref);
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user