mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The browser must load its JavaScript before React can render a task. > - A failed import can stop that process before the React error boundary exists. > - The HTML entry then leaves an empty page with no recovery action. > - This PR adds a small recovery screen that works without React. > - The user can retry the same page and return to saved task content. ## Linked Issues or Issue Description Refs #13824 and #13895. This is a follow-up to their browser startup investigation. **What happened?** Interrupting the app bundle or a required import leaves an empty React root. A startup exception has the same effect. A React error boundary cannot handle these failures because React has not started. **Expected behavior** The page must explain the startup failure and offer a manual retry. A late successful load must dismiss the recovery message without a reload. **Steps to reproduce** 1. Open a saved task in the browser. 2. Abort the application bundle request, or make a required module return HTTP 503. 3. Observe the empty page before this change. With this change, use Reload page after the fault clears and verify the saved task and comment. **Paperclip version or commit** The failing regression baseline used master at `8781f06a8`. **Deployment mode** Local source build and compiled UI. Tests cover both initial navigation and a page controlled by the production service worker. The exact cause of the older intermittent Vite stall remains unconfirmed. Forty app loads and thirty replays of retained responses did not reproduce it. This PR fixes the missing recovery path; it does not claim to remove that historical cause. A normal HTTP 304 response is not a failure. ## What Changed - Add an inline startup guard and recovery screen in the HTML entry. It does not depend on the app module graph. - Show a manual reload action after a startup error or after 30 seconds without rendered root content. - Remove the notice, timer, observer, and error listeners when the app starts. Never reload automatically. - Keep the recovery screen outside the React root so it cannot satisfy app-readiness checks. - Add browser tests for interrupted imports, a stalled import, an evaluation error, service-worker-controlled retry, repeated offline retry, and cleanup after successful startup. - Return a static, uncached HTML retry screen when a service-worker-controlled navigation fails offline. It contains no task content. - Add a full-app test that retries an interrupted compiled bundle and checks the saved task, comment, composer, route, and absence of agent runs. - Document the coverage and the limits of the historical diagnosis. ## Verification - Red baseline: four recovery cases failed; the normal-startup case passed. After the change, all five recovery cases passed. The review found an offline retry gap; that additional case failed before the worker fix and passed afterward. - Full provider-free browser-support suite: 16 passed. - Compiled-app browser tests: four passed, including saved-task reload, interrupted-bundle recovery, slow-CPU service-worker reload, and sidebar navigation. - Expanded service-worker, offline response, PWA, and worker build-ID unit tests: 37 passed. The two old plain-text offline expectations were reproduced as failures and updated for the HTML retry contract. - UI production build, full local repository typecheck (`pnpm -r typecheck`), runner-E2E typecheck, and design token checks passed. - Manual browser check: a temporary server failed the compiled bundle once. The recovery screen appeared. Clicking Reload page restored the same saved task, comment, and composer. - Full local `pnpm build` passed. - Full local `pnpm test:run` was attempted with a bounded deadline and stopped after it timed out. Workspace runtime/cleanup tests reported timeouts on this host. The monolithic local run is not a pass. The focused tests above and the complete Linux CI run provide the successful verification. - Final-head [CI run](https://github.com/paperclipai/paperclip/actions/runs/36072201966) passed. All 53 check runs succeeded; the two Storybook jobs were intentionally skipped. The legacy security status also passed. - Greptile reviewed `f83e0f51fb760541d83353f2c1df4e182f3948f9`: 5/5. Both review findings are fixed and resolved. ## Risks - The guard only handles startup before React renders root content. Existing React boundaries handle later rendering errors. - A slow startup can show the message after 30 seconds. A later successful render removes it; the page does not reload by itself. - The fallback uses native HTML when the app stylesheet is unavailable. - The worker changes only its offline navigation response. It returns static HTML with a reload button and `Cache-Control: no-store`. Its cache allowlist, private-response protections, task state, provider prompts, and grading rules stay unchanged. - This does not establish or fix the unknown cause of the historical intermittent Vite stall. ## Model Used OpenAI GPT-6 through Codex. The session exposes the GPT-6 family but not an exact served model ID or context window size. Used reasoning, code editing, shell tools, and browser testing. No subagents were used. ## 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>
73 lines
3.7 KiB
TypeScript
73 lines
3.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import { observeBrowserBootstrap } from "../runner-e2e/browser-bootstrap-diagnostics";
|
||
import { json, setup } from "./agent-chat.shared";
|
||
|
||
test.use({ serviceWorkers: "allow", trace: "retain-on-failure" });
|
||
|
||
test("saved task content survives same-URL navigation and reload with a controlling service worker", async ({ page, request }) => {
|
||
test.setTimeout(120_000);
|
||
const fixture = await setup(request);
|
||
const diagnostics = observeBrowserBootstrap(page);
|
||
const title = "Task reload continuity";
|
||
const comment = "Persisted context remains available after a full page load.";
|
||
try {
|
||
// Unassigned backlog work exercises the actual UI without a provider call.
|
||
const issue = await json(await request.post(`/api/companies/${fixture.company.id}/issues`, {
|
||
data: { title, status: "backlog" },
|
||
}));
|
||
await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body: comment } }));
|
||
const route = `/${fixture.company.issuePrefix}/issues/${issue.identifier}`;
|
||
const assertLoaded = async () => {
|
||
await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({ timeout: 30_000 });
|
||
await expect(page.getByText(comment, { exact: true })).toBeVisible();
|
||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||
};
|
||
await page.goto(route, { waitUntil: "domcontentloaded" });
|
||
await assertLoaded();
|
||
await page.waitForFunction(() => Boolean(navigator.serviceWorker?.controller));
|
||
for (let index = 0; index < 3; index += 1) {
|
||
// The warm-continuity eval used same-URL goto, not just location.reload.
|
||
await page.goto(route, { waitUntil: "domcontentloaded" });
|
||
await assertLoaded();
|
||
await page.reload({ waitUntil: "domcontentloaded" });
|
||
await assertLoaded();
|
||
}
|
||
expect(await json(await request.get(`/api/issues/${issue.id}/runs`))).toEqual([]);
|
||
} finally {
|
||
await test.info().attach("browser-bootstrap", {
|
||
contentType: "application/json",
|
||
body: Buffer.from(JSON.stringify(await diagnostics.snapshot(), null, 2)),
|
||
});
|
||
diagnostics.dispose();
|
||
await fixture.restore();
|
||
}
|
||
});
|
||
|
||
test("an interrupted app bundle offers a retry that restores the saved task", async ({ page, request }) => {
|
||
const fixture = await setup(request);
|
||
const title = "Recover interrupted startup";
|
||
const comment = "This saved comment must survive a startup failure.";
|
||
try {
|
||
const issue = await json(await request.post(`/api/companies/${fixture.company.id}/issues`, {
|
||
data: { title, status: "backlog" },
|
||
}));
|
||
await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body: comment } }));
|
||
const route = `/${fixture.company.issuePrefix}/issues/${issue.identifier}`;
|
||
// Fail the shipped module before React (and its error boundary) can start.
|
||
await page.route("**/assets/*.js", intercepted => intercepted.abort());
|
||
await page.goto(route);
|
||
await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible();
|
||
expect(await page.locator("#root").evaluate(root => root.childElementCount)).toBe(0);
|
||
await page.unroute("**/assets/*.js");
|
||
await page.getByRole("button", { name: "Reload page" }).click();
|
||
await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible();
|
||
await expect(page.getByText(comment, { exact: true })).toBeVisible();
|
||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||
await expect(page.locator("#paperclip-startup")).toBeHidden();
|
||
expect(new URL(page.url()).pathname).toBe(route);
|
||
expect(await json(await request.get(`/api/issues/${issue.id}/runs`))).toEqual([]);
|
||
} finally {
|
||
await fixture.restore();
|
||
}
|
||
});
|