mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from task lists, the Inbox and the sidebar many times each hour > - A task's chat shows its first frame only after about seven separate reads finish > - #20355 added one batched `chat-bootstrap` read that returns all of these reads. #20450 used it only for the sidebar's Recents > - The hosted database is CPU-bound, so the UI must open tasks faster and must also send fewer requests > - This pull request uses the bootstrap for task lists, for open intent on any task link, for cold opens and for a hard load of a task URL. It shares one coordination module between all of these paths > - The benefit is that a task opens from cache more often, a cold open sends one chat request instead of about seven, and the first content paint comes sooner ## Linked Issues or Issue Description Refs #20355 (server read that this pull request consumes). Refs #20450 (Recents preload, now on the shared path). **Is your feature request related to a problem?** A click on a task in a task list waited for about seven separate chat reads. The default task list (Coordinator) had no preload at all. A press or a resting hover on a task link sent the same seven reads, and the page then sent a second set. A hard load of a task URL started its chat reads only after the JavaScript bundle and the company list loaded. Sidebar project task links had no open-intent prefetch. **Describe the solution you'd like** Use the single `chat-bootstrap` read everywhere a task is about to open. Let the page's gating reads share a bootstrap that is in flight instead of sending their own requests. Keep the core issue read separate so the header does not wait for the larger read. **Describe alternatives you've considered** Preload 30 rows per list in three requests (the first prototype). Each task costs the server about fifty queries, and a resting hover warms any other row before the click, so this change preloads 10 rows in one request. ## What Changed - `ui/src/lib/issue-chat-bootstrap.ts`: one module decides when to send a bootstrap and lets page reads share it (`readFromIssueChatBootstrap`). Seeding uses the existing `issueChatBootstrapCache` (replace-style reads are refreshed when newer, merge-style reads are filled only when empty, other companies are ignored). It skips tasks that are loading, loaded in the last 30 seconds, or already fresh in cache, under the id or the identifier. It never aborts a request in flight, so a click that unmounts the list still uses the answer. Each request is bound to the account that sent it; a request sent before the first session read is bound to the first account the session reports, and a sign-out revokes it. A page read shares a bootstrap only while its key holds no data (a refresh after a write always reads the server), and waits at most 5 seconds for it. Every ref of a task (id and identifier) is tracked at request start. Entries expire after 30 seconds. - Task lists preload the chat of their top 10 rows in one request, after the list's own reads settle and the browser is idle (`usePreloadTaskChats`, `when-page-settles.ts`): the Coordinator task list (`TaskList` with `preloadCompanyId`), the streamlined and legacy issues lists and the streamlined Inbox. Recents now uses the same path, so a task in both places loads once. A list that stays open preloads each task at most once in five minutes: live events (#20563's lanes) keep a preloaded task's cache current or mark it stale, and a stale key is read from the server, never from an older bootstrap. All seeded keys are per-task keys on the fast live lane, not the ambient lane. - Open intent (press, click, resting hover or focus) on a task with no chat history in cache warms it with one bootstrap request instead of six separate reads (`prefetchIssueDetailForNavigation`). A task seen before still refreshes its cached reads one by one. - A cold open starts one bootstrap. The gating reads (recent comments, activity, runs, input facts, interactions, attachments, live runs, active run) wait for it instead of sending their own requests. The core issue read stays independent. - A hard load of a task URL starts `GET /api/issues/:ref/chat-bootstrap` from the inline preload script, in parallel with the JavaScript bundle. The page adopts that answer. - Sidebar project task links (Coordinator sidebar) get the same hover, press and focus warming as Recents and `IssueLinkQuicklook`. - Tests: unit tests for the coordination module, Recents, `TaskList` preload, sidebar warming and the cold-intent path. A new layout journey checks that a task the Inbox preloaded reveals once at its latest message without reading its chat again. Journeys that hold one separate read now also hold or fail the bootstrap. The Inbox reveal journey held a read that the chat no longer sends on open (it was flaky on master too), so it now holds the recent comment window. - The perf harness gains `PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS` and the README documents it. ## Verification Measurement setup: production UI builds of master (`e01fe7f84a` and the earlier base `8d2bf78323`) and of this branch, served by one local server with `PAPERCLIP_UI_DEV_MIDDLEWARE=false` and embedded PostgreSQL. Only `index.html` changed between variants (both asset sets were present), so the server, database and seeded task were the same. Four rounds of the issue-detail harness, with master and branch alternated in each round: 20 pairs per S1 cell, 40 per S2 cell. The host was shared with other agents (load average 16 to 43), so the spread is large. Content paint (the task is readable), median paired delta, branch minus master: | Scenario | Profile | Pairs | Content paint | Branch faster in | API requests before content (master / branch) | |---|---|---:|---:|---:|---:| | S1 preloaded click (dwell 1500 ms) | unthrottled | 20 | -45 ms | 15/20 | 25 / 18 | | S1 preloaded click (dwell 1500 ms) | Fast 4G, 4x CPU | 20 | -23 ms | 13/20 | 26 / 18 | | S1 click, no dwell | unthrottled | 20 | -51 ms | 15/20 | 25 / 18 | | S1 click, no dwell | Fast 4G, 4x CPU | 20 | -21 ms | 12/20 | 26 / 18 | | S2 cold deep link | unthrottled | 40 | -61 ms | 28/40 | 54 / 40 | | S2 cold deep link | Fast 4G, 4x CPU | 40 | -129 ms | 29/40 | 47 / 39 | Medians (p25 to p75), content paint in ms: S1 dwell unthrottled 295 (283-315) → 248 (217-276). S1 dwell Fast 4G 1133 (1114-1226) → 1093 (853-1176). S1 no dwell unthrottled 295 (277-328) → 260 (233-332). S1 no dwell Fast 4G 984 (842-1196) → 923 (816-1110). S2 unthrottled 1002 (677-1235) → 896 (619-1280). S2 Fast 4G 4643 (4155-5061) → 4516 (4298-4894). Header paint is neutral to slightly later in two cells: S1 dwell unthrottled +39 ms (3/20 faster) and S2 Fast 4G +56 ms (17/40). In those cells the header now paints in the same frame as the content. In the first two rounds, the median gap from header to content fell from 105-157 ms to 29-74 ms unthrottled and from 557 ms to 298 ms for S2 on Fast 4G. Other header cells are -27 to +1 ms. The local server answers each read in a few milliseconds, so the network wait that the preload removes is small here. The request count is the stable result: a cold open sends one chat request instead of about seven. List page: the preload request starts after the first task row is in the DOM (64 ms after it unthrottled, 200-280 ms after it on Fast 4G, page clock). Time to the first visible row, 30-task list: unthrottled master 1863 ms vs branch 1563 ms (6 pairs, branch faster in 5). Fast 4G master 3626 ms vs branch 3727 ms (14 pairs, branch faster in 6, mixed signs). The preload starts after the row paints, so it cannot delay it; the Fast 4G difference is noise. One 10-task bootstrap took 78-162 ms on the server. Checks run: - `pnpm exec vitest run` for the bootstrap, cache, prefetch, Recents, sidebar, quicklook, `TaskList`, issues list, Inbox, issue detail and Coordinator tests: 18 files, 646 tests pass. - `pnpm exec tsc -p ui/tsconfig.json --noEmit`: clean, except missing generated Storybook catalog files that this change does not touch. - `pnpm check:token-gates`: clean. - `tests/e2e/task-loading-frame.spec.ts`, `disconnect-resilience.spec.ts` and `connection-reviews.spec.ts` (production build): 21 passed, 1 skipped. - `tests/perf/issue-detail/layout-stability.spec.ts` and `plan-startup.spec.ts` against the production build: all pass. The Inbox reveal journey and the new preloaded journey passed 4 of 4 repeats each. - `pnpm verify` asks for broader CI coverage because an e2e spec changed. ## Risks - More server work for list views: one bootstrap of up to 10 tasks per list visit (about fifty queries per task), skipped for tasks already loaded in the last 30 seconds. It runs only after the page's own reads settle and the browser is idle, and only while the tab is visible. - A resting hover on a never-opened task now sends one bootstrap instead of about six reads. The server work per task is about the same; the request count is lower. - If a bootstrap fails, each page read falls back to its own request. The fallback is slower than before for that one open (the reads start when the page mounts, not at the press). - The header can paint a few tens of milliseconds later in the same frame as the content when everything is cached. - An answer that arrives after a sign-out or account change is dropped. An answer for another company is ignored. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, Playwright runs). ## 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 - [x] 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>
255 lines
16 KiB
TypeScript
255 lines
16 KiB
TypeScript
import { expect, test, type APIRequestContext, type APIResponse } from "@playwright/test";
|
|
|
|
async function json(response: APIResponse) {
|
|
expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true);
|
|
return response.json();
|
|
}
|
|
|
|
async function createTask(request: APIRequestContext) {
|
|
const company = await json(await request.post("/api/companies", { data: { name: "Loading frame verification" } }));
|
|
const title = "Investigate a long-running workspace and preserve its saved findings, document cards, and pending review without moving the initial reading position";
|
|
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { data: {
|
|
title, status: "backlog", description: "## Saved investigation\n\n" + "Preserve the original report and all saved evidence. ".repeat(20),
|
|
} }));
|
|
return { company, issue, title, route: `/${company.issuePrefix}/issues/${issue.identifier}` };
|
|
}
|
|
|
|
for (const mobile of [false, true]) test(`a rich task uses one stable loading frame (${mobile ? "mobile" : "desktop"})`, async ({ page, request }, info) => {
|
|
test.setTimeout(60_000);
|
|
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 720 });
|
|
const fixture = await createTask(request);
|
|
try {
|
|
for (let index = 0; index < 20; index++) await json(await request.post(`/api/issues/${fixture.issue.id}/comments`, { data: {
|
|
body: `## Saved findings ${index}\n\n` + "Checked the recorded evidence and retained the result for review. ".repeat(12),
|
|
} }));
|
|
await json(await request.put(`/api/issues/${fixture.issue.id}/documents/plan`, { data: {
|
|
title: "Investigation plan", format: "markdown", body: "# Plan\n\nKeep the findings with the original task.",
|
|
} }));
|
|
await json(await request.post(`/api/issues/${fixture.issue.id}/work-products`, { data: {
|
|
type: "pull_request", provider: "github", title: "Saved investigation patch", status: "active",
|
|
url: "https://github.com/example/layout-fixture/pull/42", metadata: { repository: "example/layout-fixture", number: 42 },
|
|
} }));
|
|
await json(await request.post(`/api/issues/${fixture.issue.id}/interactions`, { data: {
|
|
kind: "request_confirmation", title: "Review the saved patch", continuationPolicy: "none",
|
|
payload: { version: 1, prompt: "Approve the saved findings?", acceptLabel: "Approve patch", rejectLabel: "Request changes" },
|
|
} }));
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
|
});
|
|
await page.addInitScript(() => {
|
|
const frames: Array<{ ready: boolean; headerVisible: boolean; loading: number[] | null; loadingComposer: number[] | null; composer: number[] | null; latestY?: number; scrollHeight?: number; loadingFont?: string; titleFont?: string }> = [];
|
|
Object.assign(window, { taskLoadingFrames: frames });
|
|
const sample = () => {
|
|
const box = (selector: string) => {
|
|
const element = document.querySelector(selector);
|
|
if (!element || getComputedStyle(element).visibility !== "visible") return null;
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return null;
|
|
return [rect.x, rect.y, rect.width, rect.height];
|
|
};
|
|
const font = (selector: string) => {
|
|
const element = document.querySelector(selector);
|
|
return element ? getComputedStyle(element).font : undefined;
|
|
};
|
|
const header = document.querySelector('[data-testid="issue-detail-header"]');
|
|
const bounds = header?.getBoundingClientRect();
|
|
frames.push({
|
|
ready: !!document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]'),
|
|
headerVisible: !!header && getComputedStyle(header).visibility === "visible" && bounds!.bottom > 60 && bounds!.top < innerHeight,
|
|
loading: box('[data-testid="task-chat-loading-frame"]'),
|
|
loadingComposer: box('[data-testid="task-chat-loading-composer"] .paperclip-task-chat-composer'),
|
|
composer: box('[data-testid="task-chat-composer-stack"] .paperclip-task-chat-composer'),
|
|
latestY: [...document.querySelectorAll('[id^="comment-"]')].at(-1)?.getBoundingClientRect().y,
|
|
scrollHeight: document.querySelector('[data-testid="task-chat-scroller"]')?.scrollHeight,
|
|
loadingFont: font('[data-slot="task-loading-title"]'),
|
|
titleFont: font('[data-testid="task-chat-history-content"] [data-testid="issue-detail-header"] h2'),
|
|
});
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
await page.goto(fixture.route);
|
|
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Approve patch", exact: true })).toBeVisible();
|
|
await page.waitForTimeout(500);
|
|
const frames = await page.evaluate(() => (window as unknown as { taskLoadingFrames: Array<{ ready: boolean; headerVisible: boolean; loading: number[] | null; loadingComposer: number[] | null; composer: number[] | null; latestY?: number; scrollHeight?: number; loadingFont?: string; titleFont?: string }> }).taskLoadingFrames);
|
|
await info.attach("loading-frames", { body: JSON.stringify(frames), contentType: "application/json" });
|
|
expect(frames.filter(frame => !frame.ready && frame.headerVisible)).toEqual([]);
|
|
const loading = frames.filter(frame => frame.loading);
|
|
const ready = frames.filter(frame => frame.ready && frame.latestY !== undefined);
|
|
expect(loading.length).toBeGreaterThan(1);
|
|
expect(ready.length).toBeGreaterThan(5);
|
|
for (let dimension = 0; dimension < 4; dimension++) {
|
|
const values = loading.map(frame => frame.loading![dimension]);
|
|
expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(2);
|
|
expect(Math.abs(loading.at(-1)!.loadingComposer![dimension] - ready[0].composer![dimension])).toBeLessThanOrEqual(2);
|
|
}
|
|
expect(loading.at(-1)!.loadingFont).toBe(ready[0].titleFont);
|
|
expect(Math.max(...ready.map(frame => frame.latestY!)) - Math.min(...ready.map(frame => frame.latestY!))).toBeLessThanOrEqual(2);
|
|
expect(new Set(ready.map(frame => frame.scrollHeight)).size).toBe(1);
|
|
} finally {
|
|
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
|
}
|
|
});
|
|
|
|
test("Classic startup does not guess a chat layout while personal preferences are pending", async ({ page, request }) => {
|
|
const fixture = await createTask(request);
|
|
let release!: () => void;
|
|
const preferenceReady = new Promise<void>(resolve => { release = resolve; });
|
|
try {
|
|
await page.route("**/api/instance/settings/experimental", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
|
});
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
await preferenceReady;
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: false } });
|
|
});
|
|
await page.goto(fixture.route);
|
|
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
|
await expect(page.locator("[data-task-chat-shell]")).toHaveCount(0);
|
|
const preparedOutlet = page.getByTestId("shell-prepared-outlet");
|
|
await expect(preparedOutlet).toHaveAttribute("hidden", "");
|
|
await expect(preparedOutlet).toHaveAttribute("inert", "");
|
|
await expect(preparedOutlet).toHaveAttribute("aria-hidden", "true");
|
|
await expect(page.getByTestId("issue-detail-header")).toBeHidden();
|
|
await expect(page.getByRole("heading", { name: fixture.title, exact: true })).toHaveCount(0);
|
|
release();
|
|
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
|
await expect(preparedOutlet).not.toHaveAttribute("hidden", "");
|
|
await expect(preparedOutlet).not.toHaveAttribute("inert", "");
|
|
await expect(preparedOutlet).not.toHaveAttribute("aria-hidden", "true");
|
|
await expect(page.locator("[data-task-chat-shell]")).toHaveCount(0);
|
|
} finally {
|
|
release();
|
|
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
|
}
|
|
});
|
|
|
|
test("a stalled web font does not hide an otherwise prepared conversation indefinitely", async ({ page, request }) => {
|
|
const fixture = await createTask(request);
|
|
let release!: () => void;
|
|
const fontReady = new Promise<void>(resolve => { release = resolve; });
|
|
try {
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
|
});
|
|
await page.route("**/*.woff2", async route => { await fontReady; await route.abort(); });
|
|
await page.goto(fixture.route, { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByTestId("task-chat-loading-frame")).toBeVisible();
|
|
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
|
} finally {
|
|
release();
|
|
await page.unrouteAll({ behavior: "wait" });
|
|
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
|
}
|
|
});
|
|
|
|
for (const coordinator of [false, true]) test(`mobile chrome stays fixed when personal preferences resolve (Coordinator=${coordinator})`, async ({ page, request }, info) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const fixture = await createTask(request);
|
|
let release!: () => void;
|
|
const preferenceReady = new Promise<void>(resolve => { release = resolve; });
|
|
await page.addInitScript(() => {
|
|
const frames: number[][] = [], frameTimes: number[] = [];
|
|
Object.assign(window, { mobileShellFrames: frames, mobileShellFrameTimes: frameTimes });
|
|
const sample = () => {
|
|
const shell = document.querySelector(".paperclip-app-shell");
|
|
if (shell && shell.getBoundingClientRect().width > 0) {
|
|
const rects = [".app-breadcrumb", ".mobile-bottom-nav", "#main-content"].map(selector => document.querySelector(selector)!.getBoundingClientRect());
|
|
frames.push([innerWidth, innerHeight, scrollX, scrollY, ...rects.flatMap((rect, index) => index === 2 ? [rect.x, rect.y, rect.width] : [rect.x, rect.y, rect.width, rect.height])]);
|
|
frameTimes.push(performance.now());
|
|
}
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
try {
|
|
await page.route("**/api/instance/settings/experimental", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
|
});
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
await preferenceReady;
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: coordinator } });
|
|
});
|
|
await page.goto(fixture.route);
|
|
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
|
await expect(page.locator(".paperclip-app-shell")).toBeHidden();
|
|
expect(await page.evaluate(() => (window as unknown as { mobileShellFrames: number[][] }).mobileShellFrames)).toEqual([]);
|
|
release();
|
|
if (coordinator) await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
|
else await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
|
const ready = await page.evaluate(() => ({
|
|
count: (window as unknown as { mobileShellFrames: number[][] }).mobileShellFrames.length,
|
|
at: performance.now(),
|
|
}));
|
|
let frames: number[][] = [];
|
|
try {
|
|
// Observe six real post-ready frames and preserve the original 300ms
|
|
// interval. Wall time alone does not establish that rAF sampled it.
|
|
await expect.poll(() => page.evaluate(({ count, at }) => {
|
|
const state = window as unknown as { mobileShellFrames: number[][]; mobileShellFrameTimes: number[] };
|
|
return state.mobileShellFrames.length >= count + 6 && (state.mobileShellFrameTimes.at(-1) ?? 0) >= at + 300;
|
|
}, ready)).toBe(true);
|
|
} finally {
|
|
const captured = await page.evaluate(() => {
|
|
const state = window as unknown as { mobileShellFrames: number[][]; mobileShellFrameTimes: number[] };
|
|
return { frames: state.mobileShellFrames, times: state.mobileShellFrameTimes };
|
|
});
|
|
frames = captured.frames;
|
|
await info.attach("mobile-preference-chrome", { body: JSON.stringify(frames), contentType: "application/json" });
|
|
await info.attach("mobile-preference-frame-times", { body: JSON.stringify({ ready, times: captured.times }), contentType: "application/json" });
|
|
}
|
|
expect(frames.length).toBeGreaterThan(5);
|
|
for (let dimension = 0; dimension < frames[0].length; dimension++) {
|
|
const values = frames.map(frame => frame[dimension]);
|
|
expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(2);
|
|
}
|
|
} finally {
|
|
release();
|
|
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
|
}
|
|
});
|
|
|
|
test("a seeded Classic loading title uses the resolved title typography", async ({ page, request }) => {
|
|
const fixture = await createTask(request);
|
|
let release!: () => void;
|
|
const coreReady = new Promise<void>(resolve => { release = resolve; });
|
|
try {
|
|
await page.route("**/api/instance/settings/experimental", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
|
});
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: false } });
|
|
});
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues`);
|
|
await page.getByRole("link").filter({ hasText: fixture.title }).first().click();
|
|
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
|
// Also hold the hard load's early chat bootstrap: it carries the task too.
|
|
await page.route(/\/api\/issues\/[^/?]+(?:\/chat-bootstrap)?(?:\?|$)/, async route => { await coreReady; await route.continue(); });
|
|
// Keep the recent-task title seed, but exercise the loading frame without
|
|
// a persisted full task that would correctly reveal the resolved header.
|
|
await page.addInitScript(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip:query-cache:v1:")).forEach(key => localStorage.removeItem(key)));
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
const loadingTitle = page.getByRole("heading", { name: fixture.title, exact: true });
|
|
await expect(loadingTitle).toBeVisible();
|
|
await expect(page.getByTestId("issue-detail-header")).toHaveCount(0);
|
|
const font = await loadingTitle.evaluate(element => getComputedStyle(element).font);
|
|
release();
|
|
const title = page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true });
|
|
await expect(title).toBeVisible();
|
|
expect(await title.evaluate(element => getComputedStyle(element).font)).toBe(font);
|
|
} finally {
|
|
release();
|
|
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
|
}
|
|
});
|