Files
PaperClipAI/tests/perf/issue-detail/layout-stability.spec.ts
T
DottaandClaude Opus 5.5 2e6850e745 perf(task-chat): open tasks from a preloaded cache and cold tasks in one request (#20592)
## 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>
2026-11-10 14:26:27 -06:00

374 lines
22 KiB
TypeScript

import fs from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(90_000);
test.use({ video: "on", trace: "retain-on-failure", viewport: { width: 1440, height: 900 } });
let prefix: string;
let issue: { id: string; identifier: string };
let other: { id: string; identifier: string };
let draftIssue: { id: string; identifier: string };
let oldestCommentId: string;
test.beforeAll(async ({ request }) => {
const company = await (await request.post("/api/companies", { data: { name: `Layout acceptance ${Date.now()}` } })).json();
prefix = company.issuePrefix;
const createIssue = async (title: string) => {
const response = await request.post(`/api/companies/${company.id}/issues`, {
data: { title, status: "backlog", description: "A rich task with a long conversation.\n\n".repeat(15) },
});
expect(response.ok(), await response.text()).toBeTruthy();
return response.json();
};
issue = await createIssue("Stable conversation acceptance");
other = await createIssue("Rapid navigation destination");
draftIssue = await createIssue("Draft stability acceptance");
for (let i = 0; i < 160; i++) {
const response = await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: `## Historical message ${i}\n\n${"Long Markdown with **emphasis**, detail, and enough text to wrap across several lines. ".repeat(8)}\n\n\`\`\`js\nconsole.log(${i});\n\`\`\`\n\n| State | Result |\n|---|---|\n| Complete | Stable |${i === 159 ? `\n\n[Read the oldest message](#comment-${oldestCommentId})` : ""}` },
});
expect(response.ok()).toBeTruthy();
if (i === 0) oldestCommentId = (await response.json()).id;
}
const interaction = await (await request.post(`/api/issues/${issue.id}/interactions`, {
data: { kind: "request_confirmation", title: "Geometry review", payload: { version: 1, prompt: "Inspect the conversation." }, continuationPolicy: "none" },
})).json();
await request.post(`/api/issues/${issue.id}/interactions/${interaction.id}/accept`, { data: {} });
});
async function ready(page: Page) {
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 90_000 });
}
async function installMeasurements(page: Page) {
await page.addInitScript(() => {
const evidence = { frames: [] as unknown[], shifts: [] as unknown[] };
Object.assign(window, { taskLayoutEvidence: evidence });
new PerformanceObserver((list) => {
evidence.shifts.push(...list.getEntries().map((e) => e.toJSON()));
}).observe({ type: "layout-shift", buffered: true });
const sample = () => {
const scroller = document.querySelector('[data-testid="task-chat-scroller"]');
const busy = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy]');
if (scroller) {
const viewport = scroller.getBoundingClientRect();
const row = [...scroller.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > viewport.top);
evidence.frames.push({ time: performance.now(), busy: busy?.getAttribute("aria-busy"), top: scroller.scrollTop, width: viewport.width, fonts: document.fonts.status, rows: scroller.querySelectorAll("[data-thread-anchor]").length, height: scroller.scrollHeight, anchor: row?.dataset.threadAnchor, offset: row ? row.getBoundingClientRect().top - viewport.top : null });
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
}
test.afterEach(async ({ page }, info) => {
const evidence = await page.evaluate(() => (window as unknown as { taskLayoutEvidence?: unknown }).taskLayoutEvidence).catch(() => null);
if (evidence) {
const file = info.outputPath("layout.json");
await fs.writeFile(file, JSON.stringify(evidence));
await info.attach("layout-frames-and-shifts", { path: file, contentType: "application/json" });
}
});
test("Inbox click reveals once after reordered initial responses; refresh retains rows", async ({ page }) => {
await installMeasurements(page);
// Exercise the separate reads: a failed chat bootstrap (the list's preload
// and the open's own) falls back to them.
await page.route(/\/chat-bootstrap(?:\?|$)/, (route) => route.abort());
await page.goto(`/${prefix}/inbox/all`);
const link = page.getByRole("link", { name: new RegExp(`Open ${issue.identifier}:`) });
await expect(link).toBeVisible({ timeout: 90_000 });
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
// The compact chat's comment window arrives last. (The chat no longer reads
// `/comments?…` on open, so holding that read did not delay the reveal.)
await page.route("**/api/issues/*/recent-comments", async (route) => { await held; await route.continue(); });
await link.click();
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="true"]')).toBeVisible();
await expect(page.getByText("Historical message 159", { exact: true })).not.toBeVisible();
release();
await ready(page);
const scroller = page.getByTestId("task-chat-scroller");
await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2);
await page.waitForTimeout(1200);
const frames = await page.evaluate(() => (window as unknown as { taskLayoutEvidence: { frames: { busy: string; top: number; anchor: string; offset: number }[] } }).taskLayoutEvidence.frames);
const visibleFrames = frames.filter((frame) => frame.busy === "false");
expect(visibleFrames.length).toBeGreaterThan(2);
expect(new Set(visibleFrames.map((f) => f.anchor)).size).toBe(1);
expect(Math.max(...visibleFrames.map((f) => f.offset)) - Math.min(...visibleFrames.map((f) => f.offset))).toBeLessThanOrEqual(2);
});
test("a task the list preloaded reveals once at the latest message, without its own chat reads", async ({ page }) => {
await installMeasurements(page);
const preloaded = page.waitForResponse(response => /\/chat-bootstrap\?/.test(response.url()) && response.ok(), { timeout: 90_000 });
await page.goto(`/${prefix}/inbox/all`);
const link = page.getByRole("link", { name: new RegExp(`Open ${issue.identifier}:`) });
await expect(link).toBeVisible({ timeout: 90_000 });
await preloaded;
const chatReads: string[] = [];
let opening = true;
page.on("request", request => {
if (opening && /\/api\/issues\/[^/]+\/(recent-comments|activity|runs|interactions|attachments)(\?|$)/.test(request.url())) chatReads.push(request.url());
});
await link.click();
await ready(page);
// Later refreshes (such as after the task is marked read) are not part of the open.
opening = false;
const scroller = page.getByTestId("task-chat-scroller");
await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2);
await page.waitForTimeout(1200);
const frames = await page.evaluate(() => (window as unknown as { taskLayoutEvidence: { frames: { busy: string; anchor: string; offset: number }[] } }).taskLayoutEvidence.frames);
const visibleFrames = frames.filter((frame) => frame.busy === "false");
expect(visibleFrames.length).toBeGreaterThan(2);
expect(new Set(visibleFrames.map((f) => f.anchor)).size).toBe(1);
expect(Math.max(...visibleFrames.map((f) => f.offset)) - Math.min(...visibleFrames.map((f) => f.offset))).toBeLessThanOrEqual(2);
// The open rendered from the preloaded cache instead of reading the chat again.
expect(chatReads).toEqual([]);
});
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();
await scroller.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; });
await page.waitForTimeout(100);
const anchor = await scroller.evaluate((el) => {
const top = el.getBoundingClientRect().top;
const row = [...el.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((r) => r.getBoundingClientRect().bottom > top)!;
return { id: row.dataset.threadAnchor!, top: row.getBoundingClientRect().top - top };
});
// Deterministic late media sizing above the reader, after the initial data
// has arrived. This exercises the actual route's ResizeObserver owner.
await scroller.evaluate((el) => {
const media = document.createElement("div");
media.style.height = "360px";
el.querySelector("[data-thread-anchor]")!.append(media);
});
const offset = () => scroller.evaluate((el, id) => {
const row = [...el.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((r) => r.dataset.threadAnchor === id)!;
return row.getBoundingClientRect().top - el.getBoundingClientRect().top;
}, anchor.id);
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
await page.getByRole("button", { name: "Hide properties", exact: true }).click();
await page.waitForTimeout(300);
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
await page.getByRole("button", { name: "Show properties", exact: true }).click();
await page.waitForTimeout(300);
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
await page.locator('[contenteditable="true"]').last().fill("A multiline draft\n".repeat(8));
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
// Invoke the real older-history control without scrolling it into view.
await page.getByRole("button", { name: /Load earlier/ }).evaluate((el) => (el as HTMLButtonElement).click());
await expect(page.getByText("Historical message 0", { exact: true })).toBeAttached();
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);
await page.getByRole("button", { name: "Scroll to latest" }).click();
await page.locator('[contenteditable="true"]').last().fill("Change the composer during the glide\n".repeat(5));
await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2);
});
test("explicit comment links resolve older pages before revealing at the target", async ({ page }) => {
await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`);
await ready(page);
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
await expect(target).toBeVisible();
await expect.poll(async () => {
const row = await target.boundingBox();
const viewport = await page.getByTestId("task-chat-scroller").boundingBox();
return Math.abs(row!.y - viewport!.y);
}).toBeLessThanOrEqual(2);
});
test("a loaded comment target positions after the reveal deadline when live-run metadata stalls", async ({ page }) => {
let release!: () => void;
let metadataReads = 0;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route("**/api/issues/*/live-runs", async route => { metadataReads++; await held; await route.continue().catch(() => {}); });
// Exercise the separate reads: a failed chat bootstrap falls back to them.
await page.route(/\/chat-bootstrap(?:\?|$)/, route => route.abort());
try {
await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`);
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 });
expect(metadataReads).toBeGreaterThan(0);
await expect(page.getByText("Some task history is still loading.", { exact: true })).toBeVisible();
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
await expect(target).toBeVisible();
await expect.poll(() => target.evaluate(el => Math.abs(el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top)))
.toBeLessThanOrEqual(2);
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeVisible();
} finally { release(); }
});
test("a late linked comment positions after the reveal deadline without losing the draft", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/issues/*/comments/${oldestCommentId}`, async route => { await held; await route.continue().catch(() => {}); });
try {
await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`);
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 });
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
await expect(target).toHaveCount(0);
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
await editor.fill("Keep this draft while the linked comment loads.");
const originalEditor = await editor.elementHandle();
release();
await expect(target).toBeVisible();
await expect.poll(() => target.evaluate(el => Math.abs(el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top)))
.toBeLessThanOrEqual(2);
await expect(editor).toHaveText("Keep this draft while the linked comment loads.");
expect(await originalEditor!.evaluate(element => element.isConnected)).toBe(true);
} finally { release(); }
});
test("a draft survives background task refresh, reload, and one persisted reply", async ({ page, request }) => {
await page.goto(`/${prefix}/issues/${draftIssue.identifier}`);
await ready(page);
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
const draft = "This draft survives task refresh and is submitted exactly once.";
await editor.fill(draft);
await editor.evaluate((el) => { el.setAttribute("data-draft-mount-proof", "retained"); });
await expect.poll(() => page.evaluate((id) => localStorage.getItem(`paperclip:issue-comment-draft:${id}`), draftIssue.id)).toBe(draft);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let refreshing = false;
await page.route(/\/api\/issues\/[^/?]+$/, async (route) => {
if (route.request().method() === "GET") {
refreshing = true;
await held;
}
await route.continue();
});
const title = "Draft stability after background update";
const updated = await request.patch(`/api/issues/${draftIssue.id}`, { data: { title } });
expect(updated.ok(), await updated.text()).toBeTruthy();
try {
await expect.poll(() => refreshing).toBe(true);
await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0);
await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained");
await expect(editor).toHaveText(draft);
} finally {
release();
}
await expect(page.getByText(title, { exact: true }).first()).toBeVisible();
await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained");
await page.reload();
await ready(page);
await expect(editor).toHaveText(draft);
await page.getByTestId("task-chat-composer-send").click();
await expect.poll(async () => {
const comments = await (await request.get(`/api/issues/${draftIssue.id}/comments`)).json();
return comments.filter((comment: { body: string }) => comment.body === draft).length;
}).toBe(1);
await page.reload();
await ready(page);
await expect(editor).toHaveText("");
await expect(page.getByText(draft, { exact: true })).toHaveCount(1);
});
test("failed initial comments expose retry, then recover without an indefinite skeleton", async ({ page }) => {
await page.route("**/api/issues/*/recent-comments", (route) => route.fulfill({ status: 500, contentType: "application/json", body: '{"error":"Comment read failed"}' }));
await page.goto(`/${prefix}/issues/${issue.identifier}`);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible({ timeout: 30_000 });
await page.unroute("**/api/issues/*/recent-comments");
await page.getByRole("button", { name: "Retry", exact: true }).click();
await ready(page);
await expect(page.getByText("Historical message 159", { exact: true })).toBeAttached();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeHidden();
});
test("mobile reduced-motion cold open and rapid task switching", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto(`/${prefix}/issues/${issue.identifier}`);
await ready(page);
const scroller = page.getByTestId("task-chat-scroller");
const distanceToLatest = () => scroller.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight);
await expect.poll(distanceToLatest).toBeLessThan(2);
await scroller.evaluate(el => { el.scrollTop = 1200; });
await page.waitForTimeout(100);
const saved = await scroller.evaluate(el => el.scrollTop);
await page.reload();
await ready(page);
await expect.poll(distanceToLatest).toBeLessThan(2);
// A fresh navigation must show the new task, never retain the previous feed.
await page.goto(`/${prefix}/issues/${other.identifier}`);
await ready(page);
await expect(page.getByText("Historical message 159", { exact: true })).not.toBeAttached();
expect(saved).toBe(1200);
});
for (const mobile of [false, true]) {
test(`same-task comment links and Back restore the mounted ${mobile ? "mobile" : "desktop"} conversation`, async ({ page }) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/${prefix}/issues/${issue.identifier}`);
await ready(page);
await page.getByText("Historical message 159", { exact: true }).waitFor({ state: "attached" });
await expect(page.locator(`[id="comment-${oldestCommentId}"]`)).toHaveCount(0);
const originalThread = await page.getByTestId("task-chat-thread").elementHandle();
const before = await page.evaluate(() => {
const root = document.querySelector('[data-testid="task-chat-scroller"]')!;
const top = root.getBoundingClientRect().top;
const row = [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > top)!;
return { id: row.dataset.threadAnchor!, offset: row.getBoundingClientRect().top - top };
});
await page.getByRole("link", { name: "Read the oldest message", exact: true }).click();
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
await expect(target).toBeAttached();
const targetOffset = () => target.evaluate(el => el.getBoundingClientRect().top - document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top);
await expect.poll(async () => Math.abs(await targetOffset())).toBeLessThanOrEqual(2);
expect(await originalThread!.evaluate((el) => el === document.querySelector('[data-testid="task-chat-thread"]'))).toBe(true);
await page.goBack();
await expect.poll(async () => Math.abs(await page.evaluate(before => {
const root = document.querySelector('[data-testid="task-chat-scroller"]')!;
const row = [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.dataset.threadAnchor === before.id)!;
return row.getBoundingClientRect().top - root.getBoundingClientRect().top - before.offset;
}, before))).toBeLessThanOrEqual(2);
});
}
test("stalled native and log history reveal loaded content with Retry after the request deadline", async ({ page }) => {
const runId = "20000000-0000-4000-8000-000000000001";
const at = new Date().toISOString();
await page.route(/\/api\/issues\/[^/]+\/runs(?:\?|$)/, (route) => route.fulfill({ json: [{ runId, runtimeMode: "native", status: "succeeded", agentId: "20000000-0000-4000-8000-000000000002", adapterType: "paperclip_runner", createdAt: at, startedAt: at, finishedAt: at }] }));
let stalled = true;
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
await page.route(`**/api/heartbeat-runs/${runId}/events?*`, async (route) => {
if (stalled) await held;
await route.fulfill({ json: [] }).catch(() => {});
});
await page.route(`**/api/heartbeat-runs/${runId}/log?*`, async (route) => {
if (stalled) await held;
await route.fulfill({ json: { runId, content: "", nextOffset: 0 } }).catch(() => {});
});
try {
await page.goto(`/${prefix}/issues/${issue.identifier}`);
await expect(page.getByTestId("task-chat-history-loading")).toBeVisible();
await expect(page.getByText("Some task history could not be loaded.")).toBeVisible({ timeout: 25_000 });
await ready(page);
await expect(page.getByText("Historical message 159", { exact: true })).toBeVisible();
stalled = false;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByText("Some task history could not be loaded.")).not.toBeVisible();
} finally {
release();
}
});