diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 35a7963f9bb..e6904ca8d2e 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -98,6 +98,11 @@ owns a compact complete task-ID index. Navigation reuses either page response. C the instance Streamlined UI setting is off. The loopback browser checks in [collection startup](../../tests/perf/collections/startup.spec.ts) exercise these reads and the initial task row budget. They do not establish deployed latency. +Collection startup selects the addressed active organization before starting +company reads. Shared controls load separately from task detail. A painted +collection or task-link intent starts a low-priority code fetch; direct task URLs keep +early module preload. The collection +browser cases cover a remembered different organization and failed speculation. The [Inbox component checks](../../ui/src/pages/Inbox.test.tsx) cover the source barrier and selected-source query; [badge tests](../../server/src/__tests__/inbox-dismissals.test.ts) cover unread counts. Browser timing and layout proof remain separate from these tests. diff --git a/tests/e2e/company-context-refresh.spec.ts b/tests/e2e/company-context-refresh.spec.ts index 309bd841b88..0a90d8a6fec 100644 --- a/tests/e2e/company-context-refresh.spec.ts +++ b/tests/e2e/company-context-refresh.spec.ts @@ -63,7 +63,7 @@ test("a refreshed company consumer reads the retained provider", async ({ page } const body = pathname === "/api/auth/get-session" ? { user: { id: "test-user" }, session: { userId: "test-user" } } : pathname === "/api/companies" - ? [{ id: "test-company", name: "Test company", status: "active" }] + ? [{ id: "test-company", name: "Test company", issuePrefix: "TST", status: "active" }] : []; await route.fulfill({ json: body }); }); diff --git a/tests/perf/collections/README.md b/tests/perf/collections/README.md index 0065e926607..eda8de20f4c 100644 --- a/tests/perf/collections/README.md +++ b/tests/perf/collections/README.md @@ -22,3 +22,21 @@ attention task index. Screenshots and failure traces stay in `test-results`. Request counts and row budgets are acceptance checks. They do not establish an end-to-end latency improvement. Measure cold, warm, and Back timings separately with the same fixture, assets, cache, viewport, and CPU/network settings. + +Startup cases also check that shared collection controls do not require the +task-detail bundle before reveal, even when a different organization is remembered. +Code warms after the collection paints, or on task-link intent. A successful +immutable response allows module preloading to compile the cached chunk without +executing it. A failed fetch leaves the collection usable and permits navigation +without a document reload. Direct task URLs preload their static dependencies +while identity is pending. These cases must run with +production assets: development modules do not exercise the bundle boundaries. + +The functional cases route feature settings and delayed responses. Routing +disables browser HTTP caching. For warm-cache timings, set the real local feature +settings and use fresh browser contexts without request interception. Keep normal +cache writes enabled so code warmed after paint can be used on the next task open. +Retain intercepted runs as separate diagnostic evidence. +Match the server's hashed asset cache policy (`public, max-age=31536000, immutable`); +Vite preview's default `no-cache` policy adds revalidation that the shipped server +does not require. Keep HTML uncached and return 404 for missing assets. diff --git a/tests/perf/collections/fixture.ts b/tests/perf/collections/fixture.ts index d0c024a3bc1..0e0d198d7c1 100644 --- a/tests/perf/collections/fixture.ts +++ b/tests/perf/collections/fixture.ts @@ -1,8 +1,25 @@ import fs from "node:fs/promises"; import { expect, type APIRequestContext, type Page } from "@playwright/test"; -export type CollectionFixture = { company: { id: string; issuePrefix: string }; taskIds: string[] }; +export type CollectionFixture = { company: { id: string; issuePrefix: string }; taskIds: string[]; otherCompanyId?: string }; export const fixturePath = "test-results/collections/fixture.json"; +async function retainOtherCompany(request: APIRequestContext, fixture: CollectionFixture): Promise { + const response = await request.get("/api/companies"); + expect(response.ok(), await response.text()).toBeTruthy(); + const companies = await response.json() as Array<{ id: string; name: string; status: string }>; + const other = companies.find(company => company.id === fixture.otherCompanyId && company.id !== fixture.company.id && company.status !== "archived") + ?? companies.find(company => company.name === "Other collection organization" && company.id !== fixture.company.id && company.status !== "archived"); + let otherCompanyId = other?.id; + if (!otherCompanyId) { + const created = await request.post("/api/companies", { data: { name: "Other collection organization" } }); + expect(created.ok(), await created.text()).toBeTruthy(); + otherCompanyId = (await created.json()).id; + } + const retained = { ...fixture, otherCompanyId }; + await fs.mkdir("test-results/collections", { recursive: true }); + await fs.writeFile(fixturePath, JSON.stringify(retained)); + return retained; +} export async function collectionFixture(request: APIRequestContext): Promise { const saved = await fs.readFile(fixturePath, "utf8").then(JSON.parse).catch(error => { if (error.code !== "ENOENT") throw error; @@ -10,7 +27,7 @@ export async function collectionFixture(request: APIRequestContext): Promise { diff --git a/tests/perf/collections/startup.spec.ts b/tests/perf/collections/startup.spec.ts index 0450bbf0b97..fd73d699694 100644 --- a/tests/perf/collections/startup.spec.ts +++ b/tests/perf/collections/startup.spec.ts @@ -2,7 +2,59 @@ import { expect, test } from "@playwright/test"; import { collectionFixture, coordinatorPreference, type CollectionFixture } from "./fixture"; let fixture: CollectionFixture; -test.beforeAll(async ({ request }) => { fixture = await collectionFixture(request); }); +let otherCompanyId: string; +test.beforeAll(async ({ request }) => { + fixture = await collectionFixture(request); + expect(fixture.otherCompanyId).toBeTruthy(); + otherCompanyId = fixture.otherCompanyId!; +}); + +test("repeated fixture setup retains both companies without growing the instance", async ({ request }) => { + const before = await (await request.get("/api/companies")).json(); + const again = await collectionFixture(request); + const after = await (await request.get("/api/companies")).json(); + expect(again.company.id).toBe(fixture.company.id); + expect(again.otherCompanyId).toBe(otherCompanyId); + expect(after.map((company: { id: string }) => company.id).sort()).toEqual(before.map((company: { id: string }) => company.id).sort()); +}); +for (const route of ["issues", "inbox/mine"]) test(`${route} leaves task-detail code out of collection startup`, async ({ page }) => { + await coordinatorPreference(page); + await page.addInitScript(id => localStorage.setItem("paperclip.selectedCompanyId", id), otherCompanyId); + const detailReads: string[] = []; + const companyReads: string[] = []; + page.on("request", request => { + if (/\/assets\/issue-detail-[^/]+\.js$/.test(request.url())) detailReads.push(request.url()); + const companyId = new URL(request.url()).pathname.match(/^\/api\/companies\/([^/]+)\//)?.[1]; + if (companyId) companyReads.push(companyId); + }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const isIndex = (url: URL) => url.pathname === `/api/companies/${fixture.company.id}/issues` && url.searchParams.get("view") === "index"; + await page.route(isIndex, async request => { await held; await request.continue(); }); + const indexRequest = page.waitForRequest(request => isIndex(new URL(request.url()))); + const warmed = page.waitForResponse(response => /\/assets\/issue-detail-[^/]+\.js$/.test(response.url())); + try { + await page.goto(`/${fixture.company.issuePrefix}/${route}`); + await indexRequest; + expect(detailReads).toEqual([]); + } finally { release(); } + const first = page.getByRole("main").locator("[data-inbox-issue-link]").first(); + await expect(first).toBeVisible(); + const node = await first.elementHandle(); + const before = await first.boundingBox(); + await (await warmed).finished(); + const after = await first.boundingBox(); + expect(await node!.evaluate(element => element.isConnected)).toBe(true); + expect(Math.abs(before!.y - after!.y)).toBeLessThan(1); + expect(Math.abs(before!.height - after!.height)).toBeLessThan(1); + expect(new Set(companyReads)).toEqual(new Set([fixture.company.id])); + await first.focus(); + await first.press("Enter"); + await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible(); + await expect(page.getByTestId("issue-detail-header")).toBeVisible(); + await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable(); +}); + for (const mobile of [false, true]) test(`Tasks use a bounded render and a shared count on ${mobile ? "mobile" : "desktop"}`, async ({ page }) => { if (mobile) await page.setViewportSize({ width: 390, height: 844 }); await coordinatorPreference(page); @@ -26,6 +78,39 @@ for (const mobile of [false, true]) test(`Tasks use a bounded render and a share await page.screenshot({ path: test.info().outputPath("tasks.png") }); }); +test("a failed speculative preload keeps the collection usable and permits task navigation", async ({ page }) => { + await coordinatorPreference(page); + let navigations = 0; + page.on("request", request => { if (request.isNavigationRequest() && request.frame() === page.mainFrame()) navigations++; }); + const pattern = /\/assets\/issue-detail-[^/]+\.js$/; + await page.route(pattern, route => route.abort("failed")); + const failure = page.waitForEvent("requestfailed", request => pattern.test(request.url())); + await page.goto(`/${fixture.company.issuePrefix}/issues`); + const first = page.getByRole("main").locator("[data-inbox-issue-link]").first(); + await expect(first).toBeVisible(); + await first.focus(); + await failure; + await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toBeEditable(); + expect(navigations).toBe(1); + await page.unroute(pattern); + await first.press("Enter"); + await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable(); + expect(navigations).toBe(1); +}); + +test("direct task opens preload their code while identity is still loading", async ({ page }) => { + await coordinatorPreference(page); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/api/auth/get-session", async route => { await held; await route.continue(); }); + try { + const detailRequest = page.waitForRequest(request => /\/assets\/issue-detail-[^/]+\.js$/.test(request.url())); + await page.goto(`/${fixture.company.issuePrefix}/issues/${fixture.taskIds[0]}`); + await detailRequest; + } finally { release(); } + await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable(); +}); + test("Coordinator Inbox retains index reads when Streamlined UI is disabled", async ({ page }) => { await coordinatorPreference(page, false); const reads: URL[] = []; diff --git a/tests/perf/issue-detail/layout-stability.spec.ts b/tests/perf/issue-detail/layout-stability.spec.ts index 1f3521d74b5..8b430037e10 100644 --- a/tests/perf/issue-detail/layout-stability.spec.ts +++ b/tests/perf/issue-detail/layout-stability.spec.ts @@ -95,9 +95,14 @@ test("Inbox click reveals once after reordered initial responses; refresh retain }); test("reading anchor survives late media, composer resizing, older history and browser Back", async ({ page }) => { + await page.route("**/api/workspace-preferences/me", async route => { + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: false } }); + }); await installMeasurements(page); await page.goto(`/${prefix}/issues/${issue.identifier}`); await ready(page); + await expect(page.getByRole("button", { name: "Hide properties", exact: true })).toBeVisible(); const scroller = page.getByTestId("task-chat-scroller"); await page.getByRole("button", { name: "More messages", exact: true }).click(); await expect(page.getByText("Historical message 10", { exact: true })).toBeAttached(); @@ -134,6 +139,9 @@ test("reading anchor survives late media, composer resizing, older history and b await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2); await page.getByRole("link", { name: "Tasks", exact: true }).first().click(); + await page.waitForURL(`**/${prefix}/issues`); + await expect(page.getByRole("main").getByRole("textbox", { name: "Search tasks", exact: true })).toBeEditable(); + await expect(page.getByTestId("task-chat-thread")).toHaveCount(0); await page.goBack(); await ready(page); await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 7d37b2f3a11..d58672744ee 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,5 @@ import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; +import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; @@ -1324,6 +1325,7 @@ function StreamlinedIssuesList({ // Resolve the initial tree before reveal: inserting ancestors/children later would move rows. // Hierarchy queries retain existing context during subsequent reads. const collectionLoading = Boolean(isLoading || externalObjectFilterLoading || (hierarchyEnabled && hierarchy.isLoading)); + useWarmTaskDetailCode(!collectionLoading && !error && filtered.length > 0); const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index f2a31edbbf9..b623a69c97a 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -1,4 +1,5 @@ import { saveTaskCollectionPreferences } from "../lib/task-collection-preferences"; +import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; @@ -1219,6 +1220,7 @@ export function IssuesList({ hierarchyEnabled, hierarchyRowBudget, ); + useWarmTaskDetailCode(!isLoading && !error && (!hierarchyEnabled || !hierarchy.isLoading) && filtered.length > 0); const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; diff --git a/ui/src/components/task-chat/TaskMessageScroller.test.tsx b/ui/src/components/task-chat/TaskMessageScroller.test.tsx index 24bd3e99097..09989ab66a8 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.test.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.test.tsx @@ -211,6 +211,10 @@ describe("TaskMessageScroller", () => { width = 500; rowTop = 80; for (const callback of resize) callback(); expect(scroller().scrollTop).toBe(130); + rowTop = 70; + await dispatch(scroller(), new Event("scroll", { bubbles: true })); + for (const callback of resize) callback(); + expect(scroller().scrollTop).toBe(120); width = 600; rowTop = 60; await dispatch(scroller(), new Event("scroll", { bubbles: true })); for (const callback of resize) callback(); diff --git a/ui/src/components/task-chat/TaskMessageScroller.tsx b/ui/src/components/task-chat/TaskMessageScroller.tsx index dba3d3863fa..e4d29ab117d 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.tsx @@ -191,6 +191,7 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling const handleScroll = useCallback(() => { if (!ref.current?.clientHeight) return; const top = ref.current.scrollTop; + const moved = top !== previousScrollTop.current; const scrollingUp = top < previousScrollTop.current; previousScrollTop.current = top; if ((scrollingUp || upwardIntent.current) && top <= 192 && !easingRef.current) requestEarlierHistory(); @@ -213,11 +214,11 @@ export function TaskMessageScroller({ children, contentKey, className, scrolling pinnedRef.current = true; hidePill(); } - rememberAnchor(); + rememberAnchor(moved); return; } pinnedRef.current = pinned; - rememberAnchor(); + rememberAnchor(moved); if (pinned) hidePill(); else showPill(); }, [ diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index cbd1652edfa..a9053e97177 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -87,6 +87,24 @@ function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: strin } describe("resolveBootstrapCompanySelection", () => { + it("boots the addressed company before the remembered company", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany, secondActiveCompany], sidebarCompanies: [activeCompany, secondActiveCompany], + selectedCompanyId: null, storedCompanyId: activeCompany.id, routeCompanyId: secondActiveCompany.id, + })).toBe(secondActiveCompany.id); + }); + + it("retains a manual selection and does not bootstrap into an archived route", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany, secondActiveCompany], sidebarCompanies: [activeCompany, secondActiveCompany], + selectedCompanyId: activeCompany.id, storedCompanyId: activeCompany.id, routeCompanyId: secondActiveCompany.id, + })).toBe(activeCompany.id); + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany, archivedCompany], sidebarCompanies: [activeCompany], + selectedCompanyId: null, storedCompanyId: activeCompany.id, routeCompanyId: archivedCompany.id, + })).toBe(activeCompany.id); + }); + it("does not expose a stale stored company id before companies load", () => { expect(resolveBootstrapCompanySelection({ companies: [], @@ -218,6 +236,28 @@ describe("CompanyProvider", () => { vi.clearAllMocks(); }); + it("publishes the URL company without first exposing the remembered company", async () => { + const previousUrl = window.location.href; + window.history.replaceState(null, "", "/TWO/issues"); + localStorage.setItem("paperclip.selectedCompanyId", "company-1"); + queryClient.setQueryData(queryKeys.auth.session, null); + queryClient.setQueryData(queryKeys.companies.list(null), { + companies: [{ ...makeCompany("company-1"), issuePrefix: "ONE" }, { ...makeCompany("company-2"), issuePrefix: "TWO" }], + unauthorized: false, + }); + mockCompaniesApi.list.mockImplementation(() => new Promise(() => {})); + const seen: Array = []; + try { + await act(async () => root.render( + seen.push(id)} /> + )); + expect(seen.filter(Boolean)).toEqual(["company-2"]); + expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2"); + } finally { + window.history.replaceState(null, "", previousUrl); + } + }); + it("keeps the stored company when the company request fails", async () => { // The seam the predicate test above cannot reach: the provider defaults a // failed request to an empty list, so the effect has to be told the diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 677875216fb..998f51b4ddf 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -58,6 +58,7 @@ export function resolveBootstrapCompanySelection(input: { sidebarCompanies: Array>; selectedCompanyId: string | null; storedCompanyId: string | null; + routeCompanyId?: string | null; }) { if (input.companies.length === 0) return null; @@ -75,6 +76,9 @@ export function resolveBootstrapCompanySelection(input: { if (input.selectedCompanyId && input.companies.some((company) => company.id === input.selectedCompanyId)) { return input.selectedCompanyId; } + if (input.routeCompanyId && input.sidebarCompanies.some((company) => company.id === input.routeCompanyId)) { + return input.routeCompanyId; + } if (input.storedCompanyId && selectableCompanies.some((company) => company.id === input.storedCompanyId)) { return input.storedCompanyId; } @@ -182,6 +186,9 @@ export function CompanyProvider({ children }: { children: ReactNode }) { sidebarCompanies, selectedCompanyId, storedCompanyId: localStorage.getItem(STORAGE_KEY), + routeCompanyId: companies.find(company => + company.issuePrefix.toLowerCase() === window.location.pathname.split("/")[1]?.toLowerCase(), + )?.id, }); if (next === null || next === selectedCompanyId) return; setSelectedCompanyIdState(next); diff --git a/ui/src/hooks/useWarmTaskDetailCode.ts b/ui/src/hooks/useWarmTaskDetailCode.ts new file mode 100644 index 00000000000..890133ccfc4 --- /dev/null +++ b/ui/src/hooks/useWarmTaskDetailCode.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { TASK_COLLECTION_PAINTED_EVENT } from "@/lib/issue-detail-preload-events"; + +export function useWarmTaskDetailCode(ready: boolean) { + useEffect(() => { + if (!ready || typeof window.requestAnimationFrame !== "function") return; + let second: number | undefined; + const first = window.requestAnimationFrame(() => { + second = window.requestAnimationFrame(() => window.dispatchEvent(new Event(TASK_COLLECTION_PAINTED_EVENT))); + }); + return () => { + window.cancelAnimationFrame(first); + if (second !== undefined) window.cancelAnimationFrame(second); + }; + }, [ready]); +} diff --git a/ui/src/lib/issue-detail-preload-events.ts b/ui/src/lib/issue-detail-preload-events.ts new file mode 100644 index 00000000000..47f8e232827 --- /dev/null +++ b/ui/src/lib/issue-detail-preload-events.ts @@ -0,0 +1 @@ +export const TASK_COLLECTION_PAINTED_EVENT = "paperclip:task-collection-painted"; diff --git a/ui/src/lib/vite-issue-detail-preload.test.ts b/ui/src/lib/vite-issue-detail-preload.test.ts new file mode 100644 index 00000000000..c94262ebdfa --- /dev/null +++ b/ui/src/lib/vite-issue-detail-preload.test.ts @@ -0,0 +1,83 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; +import { preloadIssueDetail } from "./vite-issue-detail-preload"; +import { TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; + +function bootstrap(pathname: string, imports: Record = {}, responseOk = true, cacheControl = "public, max-age=31536000, immutable") { + const hook = preloadIssueDetail().transformIndexHtml; + if (typeof hook !== "function") throw new Error("Missing HTML transform"); + const tags = hook.call({} as never, "", { bundle: { detail: { + type: "chunk", fileName: "assets/issue-detail-fixture.js", moduleIds: ["/app/src/pages/IssueDetail.tsx"], + imports: imports["assets/issue-detail-fixture.js"] ?? [], + }, ...Object.fromEntries(Object.entries(imports).filter(([fileName]) => fileName !== "assets/issue-detail-fixture.js").map(([fileName, dependencies]) => [fileName, { + type: "chunk", fileName, moduleIds: [], imports: dependencies, + }])) } } as never) as { tag: string; children: string }[]; + const links: { rel: string; href: string; crossOrigin: string }[] = []; + const fetches: unknown[][] = []; + const listeners = new Map void>(); + runInNewContext(tags[0].children, { + location: { pathname, origin: "https://board.example" }, + fetch: (...args: unknown[]) => { fetches.push(args); return Promise.resolve({ ok: responseOk, headers: { get: () => cacheControl }, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); }, + window: { addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener) }, + document: { + createElement: () => ({}), + head: { appendChild: (link: typeof links[number]) => links.push(link) }, + addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), + }, + }); + return { links, fetches, painted: () => listeners.get(TASK_COLLECTION_PAINTED_EVENT)!({}), intent: (type: string, pathname: string, origin = "https://board.example") => { + listeners.get(type)!({ target: { closest: () => ({ pathname, origin }) } }); + } }; +} + +it.each(["/PAP/issues/PAP-1", "/PAP/issues/PAP-1/", "/issues/PAP-1", "/PAP/chats/helper"])("preloads a direct conversation at %s", pathname => { + expect(bootstrap(pathname).links).toEqual([{ rel: "modulepreload", href: "/assets/issue-detail-fixture.js", crossOrigin: "" }]); +}); + +it.each(["/PAP/issues", "/PAP/inbox", "/PAP/inbox/mine", "/PAP/chats", "/PAP/issues/all", "/PAP/issues/active", "/PAP/issues/backlog", "/PAP/issues/done", "/PAP/issues/recent", "/issues", "/auth"])("leaves task detail out of %s startup", pathname => { + expect(bootstrap(pathname).links).toEqual([]); +}); + +it.each(["pointerover", "pointerdown", "focusin"])("preloads once on same-origin task or chat %s intent", type => { + const page = bootstrap("/PAP/inbox/mine"); + page.intent(type, "/PAP/issues"); + page.intent(type, "/PAP/issues/PAP-1", "https://other.example"); + expect(page.links).toEqual([]); + expect(page.fetches).toEqual([]); + page.intent(type, "/PAP/issues/PAP-1"); + page.intent(type, "/PAP/chats/helper"); + expect(page.links).toEqual([]); + expect(page.fetches).toEqual([["/assets/issue-detail-fixture.js", { credentials: "same-origin", cache: "force-cache", priority: "low" }]]); +}); + +it("does not add a preload when the build has no task-detail chunk", () => { + const hook = preloadIssueDetail().transformIndexHtml; + if (typeof hook !== "function") throw new Error("Missing HTML transform"); + expect(hook.call({} as never, "", { bundle: {} } as never)).toEqual([]); +}); + +it("warms code after a collection paints without executing it or repeating the fetch", () => { + const page = bootstrap("/PAP/issues"); + expect(page.fetches).toEqual([]); + page.painted(); + page.intent("focusin", "/PAP/issues/PAP-1"); + expect(page.links).toEqual([]); + expect(page.fetches).toHaveLength(1); +}); + +it("preloads direct-task dependencies once, including cycles without external imports", () => { + const page = bootstrap("/PAP/issues/PAP-1", { + "assets/issue-detail-fixture.js": ["assets/shared.js"], + "assets/shared.js": ["assets/nested.js", "assets/issue-detail-fixture.js", "external"], + "assets/nested.js": [], + }); + expect(page.links.map(link => link.href)).toEqual(["/assets/issue-detail-fixture.js", "/assets/shared.js", "/assets/nested.js"]); +}); + +it.each([[true, "public, max-age=31536000, immutable", 1], [false, "public, max-age=31536000, immutable", 0], [true, "no-cache", 0]] as const)("compiles warmed code only after a successful immutable response (%s, %s)", async (ok, cacheControl, linkCount) => { + const page = bootstrap("/PAP/issues", {}, ok, cacheControl); + page.painted(); + await new Promise(resolve => setImmediate(resolve)); + expect(page.links).toHaveLength(linkCount); + expect(page.fetches).toHaveLength(1); +}); diff --git a/ui/src/lib/vite-issue-detail-preload.ts b/ui/src/lib/vite-issue-detail-preload.ts new file mode 100644 index 00000000000..9d41da59bdf --- /dev/null +++ b/ui/src/lib/vite-issue-detail-preload.ts @@ -0,0 +1,63 @@ +import type { Plugin } from "vite"; +import { TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; + +export function preloadIssueDetail(): Plugin { + return { + name: "paperclip-preload-issue-detail", + apply: "build", + transformIndexHtml(_html, { bundle }) { + const issueDetail = Object.values(bundle ?? {}).find((chunk) => + chunk.type === "chunk" && chunk.moduleIds.some((id) => id.endsWith("/src/pages/IssueDetail.tsx")), + ); + if (!issueDetail) return []; + const chunks = new Map(Object.values(bundle ?? {}).filter(chunk => chunk.type === "chunk").map(chunk => [chunk.fileName, chunk])); + const dependencies = new Set(); + const visit = (fileName: string) => { + if (!chunks.has(fileName) || dependencies.has(fileName)) return; + dependencies.add(fileName); + for (const imported of chunks.get(fileName)?.imports ?? []) visit(imported); + }; + visit(issueDetail.fileName); + return [{ + tag: "script", + injectTo: "head-prepend", + children: `(() => { + const isDetail = pathname => { + const parts = pathname.split("/").filter(Boolean); + const route = parts.length === 3 ? parts[1] : parts.length === 2 ? parts[0] : null; + return route === "chats" || (route === "issues" && !["all", "active", "backlog", "done", "recent"].includes(parts[parts.length - 1])); + }; + const append = href => { + const link = document.createElement("link"); + link.rel = "modulepreload"; + link.href = href; + link.crossOrigin = ""; + document.head.appendChild(link); + }; + let requested = false; + const preload = (speculative = false) => { + if (requested) return; + requested = true; + if (speculative) { + void fetch(${JSON.stringify(`/${issueDetail.fileName}`)}, { credentials: "same-origin", cache: "force-cache", priority: "low" }).then(async response => { + await response.arrayBuffer(); + if (response.ok && response.headers.get("cache-control")?.split(",").some(value => value.trim().toLowerCase() === "immutable")) append(${JSON.stringify(`/${issueDetail.fileName}`)}); + }).catch(() => {}); + return; + } + for (const href of ${JSON.stringify([...dependencies].map(fileName => `/${fileName}`))}) append(href); + }; + if (isDetail(location.pathname)) preload(); + window.addEventListener(${JSON.stringify(TASK_COLLECTION_PAINTED_EVENT)}, () => preload(true), { once: true }); + const onIntent = event => { + const link = event.target.closest?.("a[href]"); + if (link?.origin === location.origin && isDetail(link.pathname)) preload(true); + }; + document.addEventListener("pointerover", onIntent, { passive: true }); + document.addEventListener("pointerdown", onIntent, { passive: true }); + document.addEventListener("focusin", onIntent); + })();`, + }]; + }, + }; +} diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 5234b3d60ff..6a9e4208944 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -1,4 +1,5 @@ import { useDeferredValue, useEffect, useRef } from "react"; +import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { ListChecks, SearchX } from "lucide-react"; import { accessApi } from "@/api/access"; @@ -180,6 +181,7 @@ export function CoordinatorTasks() { ?? (collection.filters.liveOnly ? liveRuns.error : null); const pending = (needsAttention ? attention.isPending || attentionTasks.some(task => task.isPending) : tasks.isPending) || (collection.filters.liveOnly && liveRuns.isPending); + useWarmTaskDetailCode(!pending && !error && items.length > 0); const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( companyId, listRef, `${collection.visibleItems.map(item => item.issue.id).join(",")}:${[...collection.collapsedIds].join(",")}:${collection.layout}`, diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 9c5d7ab2a9e..1cb9f35f953 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -928,13 +928,16 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); - it("can select a company after a cold inbox mount", async () => { + it.each([true, false])("can select a company after a cold inbox mount (Streamlined UI=%s)", async streamlined => { companyState.selectedCompanyId = null; + const settings = { enableStreamlinedUi: streamlined, enableIsolatedWorkspaces: false }; + apiMocks.experimentalSettings.mockResolvedValue(settings); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + queryClient.setQueryData(queryKeys.instance.experimentalSettings, settings); const root = createRoot(container); const render = () => root.render(); await act(render); - await vi.waitFor(() => expect(container.textContent).toContain("Select an organization")); + await vi.waitFor(() => expect(container.textContent).toContain(streamlined ? "Select an organization" : "Select a company")); companyState.selectedCompanyId = "company-1"; apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]); await act(render); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index cea5ccbdf7d..ff6c08e6767 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,4 +1,5 @@ import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation"; +import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { describeError } from "../api/errors"; import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice"; @@ -2483,6 +2484,7 @@ function StreamlinedInbox({ if (initialSourcesSettled && !listError) setRevealedScope(revealScope); }, [initialSourcesSettled, listError, revealScope]); const allLoaded = initialSourcesSettled || revealedScope === revealScope; + useWarmTaskDetailCode(allLoaded && !listError && tab !== "blocked" && showWorkItemsSection); const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index 460b072b118..520539c6964 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -1,4 +1,5 @@ import { describeError } from "../api/errors"; +import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -2209,6 +2210,16 @@ export function Inbox() { if (row) row.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); + const allLoaded = + !isJoinRequestsLoading && + !isApprovalsLoading && + !isDashboardLoading && + !isIssuesLoading && + !isMineIssuesLoading && + !isTouchedIssuesLoading && + !isRunsLoading; + useWarmTaskDetailCode(Boolean(selectedCompanyId) && allLoaded && tab !== "blocked" && totalVisibleWorkItems > 0); + if (!selectedCompanyId) { return ; } @@ -2243,15 +2254,6 @@ export function Inbox() { showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); - const allLoaded = - !isJoinRequestsLoading && - !isApprovalsLoading && - !isDashboardLoading && - !isIssuesLoading && - !isMineIssuesLoading && - !isTouchedIssuesLoading && - !isRunsLoading; - const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; const markAllReadIssues = (tab === "mine" ? visibleMineIssues : unreadTouchedIssues) .filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id)); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2e71bdc76d9..623328f6196 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,27 +1,15 @@ import path from "path"; -import { defineConfig, type Plugin } from "vite"; +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { createUiDevWatchOptions } from "./src/lib/vite-watch"; import { createApiProxy } from "./src/lib/vite-api-proxy"; import { serviceWorkerBuildIdPlugin } from "./src/lib/vite-sw-build-id"; import { readBrowserBuildCommit } from "./src/lib/vite-build-commit"; +import { preloadIssueDetail } from "./src/lib/vite-issue-detail-preload"; const apiProxy = createApiProxy(); -function preloadIssueDetail(): Plugin { - return { - name: "paperclip-preload-issue-detail", - apply: "build", - transformIndexHtml(_html, { bundle }) { - const issueDetail = Object.values(bundle ?? {}).find((chunk) => - chunk.type === "chunk" && chunk.moduleIds.some((id) => id.endsWith("/src/pages/IssueDetail.tsx")), - ); - return issueDetail ? [{ tag: "link", attrs: { rel: "modulepreload", href: `/${issueDetail.fileName}`, crossorigin: "" }, injectTo: "head" }] : []; - }, - }; -} - export default defineConfig(({ mode }) => ({ define: { __PAPERCLIP_BUILD_COMMIT__: JSON.stringify( @@ -36,6 +24,7 @@ export default defineConfig(({ mode }) => ({ codeSplitting: { groups: [ { name: "board-startup", tags: ["$initial"], priority: 10 }, + { name: "collections", test: /\/src\/pages\/(?:Issues|Inbox|LegacyInbox|CoordinatorTasks)\.tsx$/, priority: 6 }, { name: "issue-detail", test: /\/src\/pages\/IssueDetail\.tsx$/, priority: 5 }, ], },