mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Mine shows the work and failures that need the viewing user's attention. > - A reload waits for account access, task rows, parent captions and failure state. > - Those reads form several browser request stages before useful rows appear. > - Live invalidations can also restart the same task-list request more than once. > - This change supplies startup data together and removes redundant refresh work. > - It preserves current authority, failure presentation, archive state and row stability. ## Linked Issues or Issue Description Refs #18645. Related: #18642. This change implements the read-receipt behavior independently on current master. It does not use that PR's branch or patch. **What happened?** Mine starts its primary request after the session, company and board-access reads. It then requests parent captions and rich failure context. Four observed loads spent another 0.45–0.70 seconds after the primary rows arrived. One slower load waited 1.71 seconds for parent context. The same run observed two HTTP 429 responses. These timings identify request waits; they do not attribute the delay to SQL or Node CPU. Read receipts also invalidate general task collections and dashboards. Overlapping parent and child invalidations can start two requests and abort the first. **Expected behavior** The initial page should use one account-safe startup projection and one complete Mine response. A read receipt should refresh the affected viewer's inbox state. Each invalidation batch should refresh a covered query once. Required captions and current failure state must be ready before first reveal. Existing rows should remain usable during a transient refresh error. **Steps to reproduce** 1. Open a populated Mine inbox with parent tasks outside the visible index and a current failed run. 2. Reload with browser HTTP cache retained and application query state empty. 3. Inspect the session, company, board access, task-index and context request sequence. 4. Open or mark a task read and inspect the unrelated collection refetches. **Paperclip version or commit** Baseline: `44a9a043b80c18314d8dd89556f444dae696d3d6`. **Deployment mode** Authenticated board UI. Local proof uses the real APIs and database with a synthetic session resolver. No hosted candidate deployment is claimed. ## What Changed - Add optional, same-user `boardBootstrap` data to the existing session response. Seed account-keyed company and board-access reads from it. Keep ordinary endpoint reads for fallback, retry and live reconciliation. A returning account cannot reuse revoked cached access. Cache collection and later real refetches remain effective. - Add the board-only Mine snapshot endpoint. Reuse the canonical issue selector, private-task filtering, run-read decisions, secret redaction and interruption/recovery presentation. Assemble the fixed 100-task page, parent captions and bounded 200-run failure window on the server. Start independent reads together. Optional source errors keep successful task rows usable. - Keep array-shaped source caches for archive and undo. Key Mine source, context and reveal state by account. Validate the response's user stamp before committing it. Remove array-only cross-tab Mine broadcasts, which cannot carry its paired failure context. The server retains authenticated in-flight coalescing. - Scope read-receipt invalidation to the affected viewer's inbox, badges and chat unread state. Keep company and task audit views fresh. Preserve genuine task mutations, run updates and subscription-gap reconciliation. - Remove covered scalar-prefix invalidations within a batch. Preserve predicates, exact matches and different query/refetch constraints. A later event can still refresh again. - Retire Mine in-flight reuse after local company activity or run lifecycle changes. A post-archive read starts fresh. Old work keeps its admission slot until it settles, even if every HTTP client aborts. The eight-compute limit stays in place. - Add API, component and browser regressions. Extend the production timing harness with matching API/UI artifact receipts, retained-cache reloads, request status counts, layout checks and optional controlled network latency. Update API and verification documentation. ## Verification - Full `pnpm -r typecheck` passes. - Full `pnpm build` passes. - Token gates, map checks and diff whitespace checks pass. - 72 Inbox component tests pass. Startup-specific checks pass, including account switching, revoked cached access, malformed/absent bootstrap data and cache collection. The canonical authenticated bootstrap route also passes against real PostgreSQL. - Six real PostgreSQL Mine route cases pass, including full equivalence with legacy authorized projections, privacy, fixed bounds, membership revocation, coalescing, archive freshness and aborted-client admission. Four focused legacy coordinator controls pass. Six assembly tests pass. - Refetch and invalidation regressions pass. Meaningful pre-fix tests observed two request starts and one abort for overlapping invalidations; the fix produces one start and no abort. The archive race test observed the post-archive GET joining the old body before the fix. - All 11 existing startup browser checks pass, including Tasks/Mine code loading, desktop/mobile bounds, non-streamlined Coordinator, task navigation, failed preloading and Back position. The nine focused startup, fallback, partial-source error, rate-limit recovery, archive/undo and geometry cases also pass. The 429 cases use the inherited retry window. Archive and keyboard undo are corroborated through the real API and reload. Authentication and failure fault injection in these cases are explicitly simulated. - Matched production before/after timings are complete. Full aggregate Vitest coverage is left to CI. At 15:15 UTC, the final-head runner gate and `review` workflow pass. The 45 build/test/policy jobs are queued; the Build job has no `ubuntu-latest` runner assigned and has executed no steps, so remote aggregate results remain unverified. Paperclip Review and Superagent Security Scan are in progress. Superseded runs for this PR were cancelled. No current-head check has reported a failure; the CI checklist remains open. The remaining step is runner allocation and completion of those external checks; persistent queueing needs the repository CI owner's attention. - Greptile completed on final head `ecde295565309275c4f2fae903caf6892f1eaafd` at 5/5. The map-scope finding is fixed and resolved; there are no open review threads. ### Matched browser timings Five alternating baseline/candidate pairs per network scenario, measured serially in headful Chrome 156.0.8078.80 on Apple silicon with Node 24.22.2 and a 1440×900 viewport. Both production UIs use their matching real API revision against one fixed disposable PostgreSQL fixture: 34 tasks, 31 visible task rows, one paused agent and one terminal failure. No active agent/provider work. HTTP cache is retained; each initial sample reloads the document and clears application query state. Warm navigation and Back retain query state. CPU and throughput are native. The 100ms scenario uses CDP minimum request-to-response-header latency without request interception. It is a controlled request-dependency experiment, not a staging reproduction. Values are median milliseconds with min–max in parentheses. Paired saving is baseline minus candidate for each alternating pair; its median differs from the difference of arm medians. Positive saving favors the candidate. | Network / journey | Baseline | Candidate | Median paired saving | | --- | ---: | ---: | ---: | | Native / initial rows | 187.6 (128.9–208.5) | 191.7 (130.3–210.9) | −23.3 | | Native / Recent → Mine | 100.17 (99.90–101.87) | 100.07 (99.88–101.37) | −0.16 | | Native / task → Back | 70.45 (56.63–72.02) | 66.64 (56.52–67.71) | +4.94 | | 100ms / initial rows | 930.3 (914.1–1015.7) | 799.0 (697.1–826.0) | +211.8 | | 100ms / Recent → Mine | 100.09 (97.73–100.16) | 100.18 (96.65–100.98) | −0.02 | | 100ms / task → Back | 74.97 (62.77–75.82) | 75.47 (61.51–76.19) | +0.35 | The controlled-latency initial pairs all improve, saving 108.3–217.0ms. Native initial pairs have mixed signs (−67.5 to +78.1ms), so they establish no reliable native page-speed improvement. Warm and Back timings are effectively unchanged. Median primary-request start is 110.4→135.2ms natively and 497.8→480.9ms with 100ms latency; these runs do not establish a large startup-only timing gain. API requests through the two-second post-reveal window fall from median 32 (32–37) to 27 (25–28) natively, and 37 (33–38) to 30 (25–32) with 100ms latency. All 20 samples preserve the same ordered 31 task links, first-row DOM identity and geometry. All recorded API responses are HTTP 200; zero unfinished requests, 429s or JavaScript errors. Lower 429 incidence on staging remains unproven. Measured source: baseline `44a9a043b80c18314d8dd89556f444dae696d3d6`, candidate `4c12b2ef0ecf0754676e99bd6b9399e84408646c`. Final head `ecde295565309275c4f2fae903caf6892f1eaafd` changes only two feature-map files and existing browser test expectations since that measured candidate; production executable inputs are identical to the measured candidate. Asset hashes, API health revision receipts and every raw sample were retained. These results do not claim hosted-page improvement or deployment of this PR. ## Risks - Startup enrichment adds work to the session read. Failure of this optional projection falls back to the ordinary company and access endpoints. It does not invalidate a valid session or replace per-request server authorization. - Account changes, stale cache entries and same-company navigation can expose old presentation data. Account keys, principal stamps, reveal scopes and canonical bootstrap replacement have dedicated negative cases. Permission failures hide cached task rows; transient network, 429 and 5xx failures preserve them. - Current failure state can differ from the historical failed run. The snapshot uses the existing canonical rich projection and verified missing-ID coverage. Partial context failures retain an explicit retry path. - Mutation retirement follows events delivered to the current server process. This adds no cross-replica invalidation protocol or distributed read-after-write guarantee. - New UI assets require the matching API. Older UI/session consumers retain their existing response and endpoint behavior. There is no database migration, provider call or deployment change. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution, browser automation and delegated source review. The exact runtime model ID and context-window size are not exposed by this session. ## 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 - [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>
171 lines
9.8 KiB
TypeScript
171 lines
9.8 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { collectionFixture, coordinatorPreference, type CollectionFixture } from "./fixture";
|
|
|
|
let fixture: CollectionFixture;
|
|
let otherCompanyId: string;
|
|
test.beforeAll(async ({ request }) => {
|
|
fixture = await collectionFixture(request);
|
|
expect(fixture.otherCompanyId).toBeTruthy();
|
|
otherCompanyId = fixture.otherCompanyId!;
|
|
});
|
|
|
|
test("repeated fixture setup retains both companies without growing the instance", async ({ request }) => {
|
|
const before = await (await request.get("/api/companies")).json();
|
|
const again = await collectionFixture(request);
|
|
const after = await (await request.get("/api/companies")).json();
|
|
expect(again.company.id).toBe(fixture.company.id);
|
|
expect(again.otherCompanyId).toBe(otherCompanyId);
|
|
expect(after.map((company: { id: string }) => company.id).sort()).toEqual(before.map((company: { id: string }) => company.id).sort());
|
|
});
|
|
for (const route of ["issues", "inbox/mine"]) test(`${route} leaves task-detail code out of collection startup`, async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
await page.addInitScript(id => localStorage.setItem("paperclip.selectedCompanyId", id), otherCompanyId);
|
|
const detailReads: string[] = [];
|
|
const companyReads: string[] = [];
|
|
page.on("request", request => {
|
|
if (/\/assets\/issue-detail-[^/]+\.js$/.test(request.url())) detailReads.push(request.url());
|
|
const companyId = new URL(request.url()).pathname.match(/^\/api\/companies\/([^/]+)\//)?.[1];
|
|
if (companyId) companyReads.push(companyId);
|
|
});
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
const isCollectionSource = (url: URL) => route === "inbox/mine"
|
|
? url.pathname === `/api/companies/${fixture.company.id}/inbox/mine`
|
|
: url.pathname === `/api/companies/${fixture.company.id}/issues` && url.searchParams.get("view") === "index";
|
|
await page.route(isCollectionSource, async request => { await held; await request.continue(); });
|
|
const sourceRequest = page.waitForRequest(request => isCollectionSource(new URL(request.url())));
|
|
const warmed = page.waitForResponse(response => /\/assets\/issue-detail-[^/]+\.js$/.test(response.url()));
|
|
try {
|
|
await page.goto(`/${fixture.company.issuePrefix}/${route}`);
|
|
await sourceRequest;
|
|
expect(detailReads).toEqual([]);
|
|
await expect(page.getByRole("main").locator("[data-inbox-issue-link]")).toHaveCount(0);
|
|
} finally { release(); }
|
|
const first = page.getByRole("main").locator("[data-inbox-issue-link]").first();
|
|
await expect(first).toBeVisible();
|
|
const node = await first.elementHandle();
|
|
const before = await first.boundingBox();
|
|
await (await warmed).finished();
|
|
const after = await first.boundingBox();
|
|
expect(await node!.evaluate(element => element.isConnected)).toBe(true);
|
|
expect(Math.abs(before!.y - after!.y)).toBeLessThan(1);
|
|
expect(Math.abs(before!.height - after!.height)).toBeLessThan(1);
|
|
expect(new Set(companyReads)).toEqual(new Set([fixture.company.id]));
|
|
await first.focus();
|
|
await first.press("Enter");
|
|
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
|
await expect(page.getByTestId("issue-detail-header")).toBeVisible();
|
|
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
|
});
|
|
|
|
for (const mobile of [false, true]) test(`Tasks use a bounded render and a shared count on ${mobile ? "mobile" : "desktop"}`, async ({ page }) => {
|
|
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
|
|
await coordinatorPreference(page);
|
|
const reads: string[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/")) reads.push(request.url()); });
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues`);
|
|
const rows = page.locator('[data-slot="task-row"][data-issue-id]');
|
|
await expect(rows).toHaveCount(100);
|
|
await expect(page.getByText("Rendering 100 of 200 tasks")).toBeVisible();
|
|
await expect.poll(() => reads.filter(url => url.includes("/attention/summary")).length).toBe(1);
|
|
expect(reads.filter(url => /\/attention\?/.test(url))).toEqual([]);
|
|
const first = await rows.first().elementHandle();
|
|
const before = await rows.first().boundingBox();
|
|
// Reaching the sentinel loads the next batch before a button click is needed.
|
|
await page.getByRole("button", { name: "Show more tasks", exact: true }).scrollIntoViewIfNeeded();
|
|
await expect(rows).toHaveCount(200);
|
|
expect(await first!.evaluate(node => node.isConnected)).toBe(true);
|
|
await rows.first().scrollIntoViewIfNeeded();
|
|
const after = await rows.first().boundingBox();
|
|
expect(Math.abs(before!.height - after!.height)).toBeLessThan(1);
|
|
await page.screenshot({ path: test.info().outputPath("tasks.png") });
|
|
});
|
|
|
|
test("a failed speculative preload keeps the collection usable and permits task navigation", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
let navigations = 0;
|
|
page.on("request", request => { if (request.isNavigationRequest() && request.frame() === page.mainFrame()) navigations++; });
|
|
const pattern = /\/assets\/issue-detail-[^/]+\.js$/;
|
|
await page.route(pattern, route => route.abort("failed"));
|
|
const failure = page.waitForEvent("requestfailed", request => pattern.test(request.url()));
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues`);
|
|
const first = page.getByRole("main").locator("[data-inbox-issue-link]").first();
|
|
await expect(first).toBeVisible();
|
|
await first.focus();
|
|
await failure;
|
|
await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toBeEditable();
|
|
expect(navigations).toBe(1);
|
|
await page.unroute(pattern);
|
|
await first.press("Enter");
|
|
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
|
expect(navigations).toBe(1);
|
|
});
|
|
|
|
test("direct task opens preload their code while identity is still loading", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route("**/api/auth/get-session", async route => { await held; await route.continue(); });
|
|
try {
|
|
const detailRequest = page.waitForRequest(request => /\/assets\/issue-detail-[^/]+\.js$/.test(request.url()));
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues/${fixture.taskIds[0]}`);
|
|
await detailRequest;
|
|
} finally { release(); }
|
|
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
|
});
|
|
|
|
test("Coordinator Inbox retains aggregate Mine reads when Streamlined UI is disabled", async ({ page }) => {
|
|
await coordinatorPreference(page, false);
|
|
const reads: URL[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
|
|
await page.goto(`/${fixture.company.issuePrefix}/inbox/mine`);
|
|
await expect(page.locator("[data-inbox-issue-link]").first()).toBeVisible();
|
|
expect(reads.filter(url => url.pathname === `/api/companies/${fixture.company.id}/inbox/mine`)).toHaveLength(1);
|
|
expect(reads.filter(url => /\/companies\/[^/]+\/issues$/.test(url.pathname) && url.searchParams.get("view") !== "rows")).toEqual([]);
|
|
expect(reads.filter(url => url.pathname.endsWith("/dashboard"))).toEqual([]);
|
|
expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]);
|
|
});
|
|
|
|
test("Needs You owns one complete feed and no redundant summary read", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
const reads: URL[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
|
|
await page.goto(`/${fixture.company.issuePrefix}/decisions`);
|
|
await expect(page.getByRole("main").getByRole("heading", { name: "Needs You", exact: true })).toBeVisible();
|
|
await expect.poll(() => reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true").length).toBe(1);
|
|
expect(reads.filter(url => url.pathname.endsWith("/attention/summary"))).toEqual([]);
|
|
});
|
|
|
|
test("Needs attention reads task membership without downloading decision cards", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
const reads: URL[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); });
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues?view=needs_attention`);
|
|
await expect(page.getByText("No tasks need a decision.", { exact: true })).toBeVisible();
|
|
expect(reads.filter(url => url.pathname.endsWith("/attention/tasks"))).toHaveLength(1);
|
|
expect(reads.filter(url => url.pathname.endsWith("/attention/summary"))).toEqual([]);
|
|
expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]);
|
|
});
|
|
|
|
test("older pages and Back keep revealed task rows and the reading position", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues`);
|
|
const rows = page.locator('[data-slot="task-row"][data-issue-id]');
|
|
await expect(rows).toHaveCount(100);
|
|
const loadPage = page.getByRole("button", { name: "Load more tasks", exact: true });
|
|
await loadPage.scrollIntoViewIfNeeded();
|
|
await expect(rows).toHaveCount(200);
|
|
const retained = await rows.last().elementHandle();
|
|
await loadPage.click();
|
|
await expect(rows).toHaveCount(220);
|
|
expect(await retained!.evaluate(node => node.isConnected)).toBe(true);
|
|
await rows.last().scrollIntoViewIfNeeded();
|
|
const before = await rows.last().boundingBox();
|
|
await rows.last().locator("[data-inbox-issue-link]").click();
|
|
await expect(page.getByTestId("issue-detail-header")).toBeVisible();
|
|
await page.goBack();
|
|
await expect(rows).toHaveCount(220);
|
|
const after = await rows.last().boundingBox();
|
|
expect(Math.abs(before!.y - after!.y)).toBeLessThan(2);
|
|
});
|