mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People open a task to read saved work and reply. > - The conversation waits for its initial data so messages do not move after reveal. > - The hidden conversation and editor still render while those reads are pending. > - Profiling showed a useful cold-load gain when that hidden rendering was delayed. > - Prepare ordinary task content after its initial data is ready. Keep the existing preparation path for comment links. > - Keep the skeleton, measured reveal, timeout, and mounted conversation during refresh. ## Linked Issues or Issue Description Follow-up to #16862 and #16849. Related task-layout work: #14758. Searches found no overlapping open rendering change. **What existing behavior does this improve?** Opening a task mounts hidden message rows and the rich editor while supporting reads are still pending. These trees can render again as data arrives before the user can see or use them. **Subsystem affected** UI task conversation startup and its browser acceptance suite. **Proposed behavior** Keep the existing skeleton and outer layout containers. Prepare the hidden conversation and composer when initial history is ready, then use the existing measured reveal. Keep content mounted after reveal. Hash navigation retains eager preparation so older comment targets can be positioned before reveal. The existing 15-second timeout still reveals available history and the composer. ## What Changed - Gate concealed history and composer children with the existing history readiness, revealed-task latch, timeout, and scroll-navigation hash. - Keep data queries, shell geometry, scroll ownership, and the reveal frame unchanged. - Update unit coverage for deferred content, comment-link preparation, task changes, history timeout, and mounted editor identity during refresh. - Add a real browser journey that holds a background task read, retains the same editor and draft, reloads the saved draft, sends one reply, and checks the persisted comment independently. - Document the loading and draft acceptance paths. ## Verification - Repository-wide `pnpm -r typecheck` and `pnpm build` passed locally. Token gates and all 231 task-thread unit cases passed. - Seven regression cases failed against the old rendering behavior before the source change. - All 14 loading and layout browser cases passed again against the final production UI and API build at `15c360aa63e01ad9318aaf7865c07d1ee0d36968`. Coverage includes delayed initial data, reading anchors, media growth, composer sizing, older history, cold comment links, desktop/mobile same-task links and Back, rapid switching, reduced motion, failed reads and retry, stalled history, plan reuse, and cached-task profiling. - The new draft journey retained the same editor during a blocked background task refresh, restored the draft after reload, and persisted exactly one reply. - Final performance comparison used current master `249ab47d41834f174ddd5e9523dc07fa8ebd8e4c` and implementation `15c360aa63e01ad9318aaf7865c07d1ee0d36968` against the same local 160-message Markdown conversation and API process. Conditions: 50 ms network latency, 9 Mbps down/1.5 Mbps up, 4x CPU slowdown, fresh browsers, five alternating pairs per scenario, no CPU profiler, and no concurrent local build/test work. Warm task navigation improved in all five pairs: median paired saving **405 ms**; variant medians **1,787 → 1,456 ms**. Cold results were mixed: paired saving **−89 ms** (slower), despite variant medians **3,188 → 3,021 ms**. There is no consistent cold-load gain established by this comparison. - Earlier prototype comparisons showed paired cold savings of 259–283 ms. Those gains did not consistently reproduce in the final comparison. These are bounded local measurements and do not establish deployed staging latency. - The first prototype delayed comment-link preparation too and failed the older-comment anchor check by 240.5 px. The narrowed implementation keeps hash navigation on the existing eager path. That case and the full suite now pass. The original failure trace is retained locally. - A visual browser walkthrough read the saved conversation and plan, restored a draft after reload, sent it once, reloaded again, and independently checked that exactly one reply was saved. - Greptile reviewed this exact commit at 5/5, with no actionable findings or inline comments. - The duplicate local `pnpm test:run` attempt was interrupted while the same suites were completing in sharded CI so local performance could be measured on an idle host. It is not counted as a local full-suite pass. - All current-head [CI jobs passed](https://github.com/paperclipai/paperclip/actions/runs/39296740548), including the server, workspace, Runner, serialized, browser, typecheck, build, canary, and final verification gates. The first run failed one native-process supervision test (`preserves a real listening process and its marker when a health RPC times out`). Its unchanged shard and dependent gate passed on retry. The specific test also passed locally. No source change was made for that transient failure. ## Risks Delaying preparation can change initial scroll geometry. Hash navigation keeps the existing preparation path because it needs the target present before positioning. The revealed-task latch keeps refreshes from removing history or the editor. The existing timeout still makes saved content and the composer accessible when reads stall. No API, database, permission, or provider changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository tools, code execution, and browser verification. A more precise runtime model identifier and context-window size are unavailable. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
289 lines
16 KiB
TypeScript
289 lines
16 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);
|
|
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; });
|
|
await page.route("**/api/issues/*/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("reading anchor survives late media, composer resizing, older history and browser Back", async ({ page }) => {
|
|
await installMeasurements(page);
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
const scroller = page.getByTestId("task-chat-scroller");
|
|
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.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 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/*/comments?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: '{"error":"Temporarily unavailable"}' }));
|
|
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/*/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);
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - window.scrollY - window.innerHeight)).toBeLessThan(2);
|
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
|
await page.waitForTimeout(100);
|
|
const saved = await page.evaluate(() => window.scrollY);
|
|
await page.reload();
|
|
await ready(page);
|
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1200);
|
|
// 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 10", { 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((mobile) => {
|
|
const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!;
|
|
const top = mobile ? 0 : 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 };
|
|
}, mobile);
|
|
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, mobile) => el.getBoundingClientRect().top - (mobile ? 0 : document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top), mobile);
|
|
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(({ mobile, before }) => {
|
|
const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!;
|
|
const row = [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.dataset.threadAnchor === before.id)!;
|
|
return row.getBoundingClientRect().top - (mobile ? 0 : root.getBoundingClientRect().top) - before.offset;
|
|
}, { mobile, 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();
|
|
}
|
|
});
|