perf(ui): reuse parent metadata when loading the inbox (#18645)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The Mine inbox combines task rows with approval and failure items.
> - It waits for parent captions before it reveals the initial list.
> - It fetched rich diagnostics for every parent, including parents
already in the task index.
> - Parent captions only use fields that the index already provides.
> - This change reuses loaded parents and fetches missing parent
metadata through the index view.
> - Failure items keep their rich interruption data and the initial
reveal boundary stays in place.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The initial load of Mine and other inbox views that need parent task
captions.

**Subsystem affected**

`ui/` collection reads. The API contract and database schema stay the
same.

**Current behavior**

The inbox fetches all parent and failed-run task IDs with `view=rows`
after the primary task request. This adds active-run, blocker, review,
interruption and recovery work to the reveal path. The observed hosted
case fetched four parents. One was already in the 31-row task index. The
other three were completed parents. The serial context response took
1.0–2.9 seconds during the diagnostic reloads.

**Proposed behavior**

Reuse parents from the selected source index. Fetch missing parents with
`view=index`. Keep `view=rows` for every failed-run task, including
overlaps with parents or source rows. Keep both role-specific ID lists
in the query key. Drop old placeholder rows when the new index supplies
a parent.

**Reason and benefit**

This removes unnecessary diagnostic work from a required serial request.
Five alternating same-four-parent requests on the hosted app had index
times of 189, 162, 156, 128 and 169 ms, versus rich-row times of 282,
211, 329, 219 and 284 ms. All requests missed the response cache. All
consumed parent fields matched. This isolates the projection cost; it is
not a deployed UI speedup claim.

**Breaking changes**

None. Membership, ordering, nesting, archive state, authorization and
failure presentation retain their owners and contracts. Refs: #18323 and
#18492 for the prior collection and startup work. This change addresses
the remaining parent-context read.

## What Changed

- Partition missing parent metadata from failed-run context. Load the
two batches concurrently, with at most two batch requests in flight.
- Keep richer failure rows last in the context result. Filter
placeholders by company and current requested IDs. Track completed
failure reads, including missing tasks, so a parent placeholder cannot
reveal an unverified failure card. Keep existing verified cards mounted
through refresh.
- Cover delayed captions, reuse of indexed parents, refreshed parent
captions, failure overlaps, resolved failures and parent-to-failure
cache transitions.
- Add a real browser check for delayed parent metadata and stable
geometry through deferred diagnostics. Give the test its own disposable
company.
- Add a reusable local timing harness and update the collection
verification recipe.

## Verification

- Baseline source: `0710c5684243a663c09ff907011ab88838173a02`. Final
measured candidate: `c8c69b5af11966b4144707a3da21e840c73c6cca`.
Production UI assets were built from the same checkout and dependencies,
with only the Inbox module changed between variants.
- Targeted regression failed on the old implementation and passed after
the change. All 58 Inbox component tests pass, including existing
archive/undo and order checks.
- Full workspace typecheck passes. The final UI typecheck passes. Full
build passes. Token gates, map checks and diff whitespace checks pass.
- All 12 collection browser checks pass, including pagination and
reading-position restoration.
- The full CI workflow passes on
`c8c69b5af11966b4144707a3da21e840c73c6cca`, including all server, chat,
workspace, serialized, runner and E2E shards. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41502803275).
Greptile reports 5/5 on this commit with no unresolved threads. The
separate Paperclip Review check is still queued. Its first attempt saved
a text result but did not complete the GitHub assessment. Normal retry
was rejected because that attempt already recorded a result. The
current-head review request is queued. The review service is now
unavailable, so this external gate remains open.
- The duplicate local `pnpm test:run` was interrupted after the
equivalent CI run passed. Its six chat shards passed 1,828 tests. The
general-server phase was stopped. This local full-suite run is
incomplete, not a passing result.
- Local synthetic fixture: four parents and 30 children, with three
completed parents archived. The fixture also has 30 archived child
records. No agent or provider calls ran. Mac arm64, Node 24.22.2,
Chromium, production assets, 1440×900, native CPU, loopback network,
fresh HTTP/query caches for each cold sample.
- Five alternating local pairs produced identical 31-row membership and
order and no browser errors:

| Journey | Baseline median (range), ms | Candidate median (range), ms |
Median paired saving, ms |
| --- | --- | --- | --- |
| Cold first usable Mine rows | 310 (252–435) | 258 (250–261) | 50 |
| Warm Recent → Mine | 60 (56–64) | 67 (61–71) | -7 |
| Task → Back to Mine | 42 (40–47) | 43 (40–45) | 0 |

The final cold result has a material control limitation. The median
primary-request start was 259 ms for baseline and 210 ms for candidate.
Much of the cold difference occurs before the changed request. The
parent response medians were 19.3 and 13.5 ms, with fewer parent IDs
requested. Do not attribute the full 52 ms difference between cold
medians to this fix. Four cold pairs were faster and one was 6 ms
slower. Warm Recent → Mine was about 7 ms slower at the median; Back was
unchanged. These runs establish no warm-navigation gain. The earlier
five-pair experiment showed the same startup confound and mixed
warm/Back results. All samples remain in the local evidence. A hosted
final-shape comparison timed out and was cancelled by closing its owned
browser tab; it supplies no result. The candidate has not been deployed.

Reproduce the contract and geometry check against an isolated production
UI with `pnpm exec playwright test --config
tests/perf/collections/playwright.config.ts parent-context.spec.ts`. Run
`tests/perf/collections/measure-mine-context.ts` against baseline and
candidate loopback production origins that share one disposable API, as
documented in `tests/perf/collections/README.md`. Preserve upstream
startup timings as controls. Required parent responses remain inside the
reveal boundary.

## Risks

- Explicit-ID `index` and `rows` requests use the same company,
visibility and actor filters. They do not use the inbox archive filter.
An archived parent is still returned by either projection. A deleted or
unreadable parent is absent in both. A second index request for a
failure-parent overlap would not restore missing caption data.
- A failed-run task must never reuse parent-only metadata. The query key
includes each projection role. Completed reads carry explicit failure-ID
coverage. The tests cover overlaps, pending role changes, late heartbeat
data, known absence, and transitions to empty views.
- Old parent context must not override a fresh indexed parent. The
current-ID placeholder filter and caption-refresh regression cover this
case.
- The hosted app still has unrelated server contention. This PR reduces
parent request work. It does not claim to fix all hosted loading delay.
Deployment acceptance should confirm the serving revision, compare
matched Mine reloads and inspect errors and row stability. Revert this
change if failure items or captions regress.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, browser
diagnostics and code execution. The exact runtime model identifier 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
- [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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-02 13:29:04 -06:00
1 parent 5a07c30c1a
commit 1f8f2e195f
6 files changed
+540 -22

No files matched your search

+5 -1
View File
@@ -91,7 +91,11 @@ Cold-open verification: load Mine with the task-index response delayed while
approvals/runs resolve. Only the stable list placeholder should appear until the
initial sources settle; a failed-run row must not jump when tasks arrive. Release
visible-row details separately and compare row/title bounds. Mine reads its own
100-row index, with parent/run context by ID; sidebar badges use the count endpoint.
100-row index. Missing parents use the same index projection for their captions;
parents already in the index are reused. Failed-run tasks still load rich row
context by ID, including when they are also parents or already indexed, so resolved
interruptions stay hidden. The initial reveal waits for both context sources.
Sidebar badges use the count endpoint.
Coordinator desktop and mobile navigation share `/attention/summary` for the
outstanding count. Needs You owns its complete feed; Tasks → Needs attention
owns a compact complete task-ID index. Navigation reuses either page response. Coordinator keeps the optimized Inbox even when
+25
View File
@@ -23,6 +23,31 @@ Request counts and row budgets are acceptance checks. They do not establish an
end-to-end latency improvement. Measure cold, warm, and Back timings separately
with the same fixture, assets, cache, viewport, and CPU/network settings.
`parent-context.spec.ts` also covers a delayed missing-parent index response,
reuse of an already indexed parent, and row geometry through deferred visible
diagnostics. Parent captions retain the initial reveal boundary. Failed-run
context and parent-to-failure cache transitions are covered by `Inbox.test.tsx`.
To collect timings, serve pinned baseline and candidate production assets from
two loopback origins that proxy the same disposable API. Then run:
```sh
INBOX_BASELINE_URL=http://127.0.0.1:<baseline-port> \
INBOX_CANDIDATE_URL=http://127.0.0.1:<candidate-port> \
node --import ./server/node_modules/tsx/dist/loader.mjs \
tests/perf/collections/measure-mine-context.ts
```
The script retains a four-parent, 30-child fixture, archives three completed
parents, and enables the personal Coordinator preference. It runs five alternating
pairs by default. `INBOX_PAIRS`, `INBOX_FIXTURE_FILE`, and `INBOX_OUTPUT` override
the count and evidence paths. Each cold sample starts with empty HTTP and query
caches. Warm Mine navigation and task-to-Mine Back retain those caches. The report
keeps every sample, errors, row membership/order, geometry, assets and API timings.
Keep upstream identity/data timings as controls: a faster cold total alone does
not attribute the gain to parent context. Use the same immutable asset cache policy
on both origins and stop builds and suites during timing runs.
Startup cases also check that shared collection controls do not require the
task-detail bundle before reveal, even when a different organization is remembered.
Code warms after the collection paints, or on task-link intent. A successful
@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { arch, loadavg, platform } from "node:os";
import { chromium, expect, request, type APIRequestContext } from "@playwright/test";
type Fixture = {
company: { id: string; issuePrefix: string };
parents: Array<{ id: string }>;
children: Array<{ id: string }>;
};
type Arm = "baseline" | "candidate";
declare global {
interface Window { __mineContextMeasure: { first: number | null } }
}
function loopbackOrigin(value: string | undefined, name: string) {
assert.ok(value, `Set ${name} to a disposable local production UI origin.`);
const url = new URL(value);
assert.ok(["http:", "https:"].includes(url.protocol)
&& ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
&& url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash,
`${name} must be an exact loopback origin.`);
return url.origin;
}
const origins = {
baseline: loopbackOrigin(process.env.INBOX_BASELINE_URL, "INBOX_BASELINE_URL"),
candidate: loopbackOrigin(process.env.INBOX_CANDIDATE_URL, "INBOX_CANDIDATE_URL"),
};
assert.notEqual(origins.baseline, origins.candidate, "Use distinct baseline and candidate UI origins.");
const pairs = Number(process.env.INBOX_PAIRS ?? 5);
assert.ok(Number.isSafeInteger(pairs) && pairs > 0, "INBOX_PAIRS must be a positive integer.");
const fixtureFile = process.env.INBOX_FIXTURE_FILE ?? "test-results/collections/mine-context-fixture.json";
const output = process.env.INBOX_OUTPUT ?? "test-results/collections/mine-context-timings.json";
assert.notEqual(path.resolve(fixtureFile), path.resolve(output), "Fixture and report paths must differ.");
async function api(client: APIRequestContext, method: string, url: string, data?: unknown) {
const response = await client.fetch(`/api${url}`, { method, data });
assert.ok(response.ok(), `${method} ${url} returned HTTP ${response.status()}.`);
return response.status() === 204 ? null : response.json();
}
async function retainFixture(client: APIRequestContext): Promise<Fixture> {
const saved = await fs.readFile(fixtureFile, "utf8").then(JSON.parse).catch(error => {
if (error.code !== "ENOENT") throw error;
return null;
}) as Fixture | null;
if (saved) {
assert.ok(saved.company?.id && saved.company.issuePrefix
&& saved.parents.length === 4 && saved.children.length === 30, "Invalid retained fixture.");
return saved;
}
const company = await api(client, "POST", "/companies", { name: "Mine parent context performance" });
const fixture: Fixture = { company: { id: company.id, issuePrefix: company.issuePrefix }, parents: [], children: [] };
const create = async (title: string, status: string, parentId?: string) => {
const row = await api(client, "POST", `/companies/${company.id}/issues`, { title, status, parentId });
return { id: row.id as string };
};
for (let index = 0; index < 4; index++) {
fixture.parents.push(await create(`Parent ${index}`, index === 0 ? "blocked" : "done"));
}
for (let index = 0; index < 30; index++) {
fixture.children.push(await create(`Inbox child ${index}`, "todo", fixture.parents[index % 4]!.id));
}
for (const parent of fixture.parents.slice(1)) await api(client, "POST", `/issues/${parent.id}/inbox-archive`, {});
await fs.mkdir(path.dirname(fixtureFile), { recursive: true });
await fs.writeFile(fixtureFile, JSON.stringify(fixture, null, 2));
return fixture;
}
const clients = {
baseline: await request.newContext({ baseURL: origins.baseline }),
candidate: await request.newContext({ baseURL: origins.candidate }),
};
const samples: Array<Record<string, unknown>> = [];
let expectedRowIds: Array<string | null> | undefined;
let fixture: Fixture | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
const save = async () => {
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.writeFile(output, JSON.stringify({
pairs, fixture, origins, rowSequenceReference: expectedRowIds,
settings: { viewport: "1440x900", httpCache: "fresh context", serviceWorkers: "blocked",
queryCache: "empty for cold, retained for warm and Back", cpu: "native", network: "loopback",
readiness: "cold: browser clock to first Mine row; warm/Back: host clock around browser actions",
node: process.version, platform: platform(), arch: arch(), browser: browser?.version() },
samples,
}, null, 2));
};
try {
for (const arm of ["baseline", "candidate"] as const) {
const response = await clients[arm].get("/");
assert.ok(response.ok(), `${arm} UI is unavailable.`);
const html = await response.text();
assert.ok(/src=["']\/assets\/[^"']+\.js["']/.test(html) && !html.includes("/@vite/client"),
`${arm} must serve a production UI build.`);
}
fixture = await retainFixture(clients.candidate);
for (const arm of ["baseline", "candidate"] as const) await api(clients[arm], "GET", `/companies/${fixture.company.id}`);
await api(clients.candidate, "PUT", `/companies/${fixture.company.id}/workspace-preferences/me`, { coordinatorWorkspaceEnabled: true });
browser = await chromium.launch();
for (let pair = 0; pair < pairs; pair++) {
const order: Arm[] = pair % 2 ? ["candidate", "baseline"] : ["baseline", "candidate"];
for (const arm of order) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" });
const page = await context.newPage();
const errors: string[] = [];
const reads: Array<{ view: string | null; ids: number }> = [];
let rowIds: Array<string | null> | undefined;
page.on("pageerror", error => errors.push(error.message));
page.on("request", request => {
const url = new URL(request.url());
if (url.pathname.endsWith("/issues")) reads.push({ view: url.searchParams.get("view"), ids: url.searchParams.get("ids")?.split(",").length ?? 0 });
});
try {
await page.addInitScript(() => {
window.__mineContextMeasure = { first: null };
const observer = new MutationObserver(() => {
if (window.__mineContextMeasure.first === null && location.pathname.endsWith("/inbox/mine") && document.querySelector("[data-inbox-item]")) {
window.__mineContextMeasure.first = performance.now();
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true });
});
await page.goto(`${origins[arm]}/${fixture.company.issuePrefix}/inbox/mine`);
const rows = page.locator("[data-inbox-item]");
await expect(rows.first()).toBeVisible();
const cold = await page.evaluate(() => window.__mineContextMeasure.first);
assert.ok(cold !== null, "Cold readiness observer did not see a row.");
rowIds = await page.locator("[data-inbox-issue-link]").evaluateAll(nodes => nodes.map(node => node.getAttribute("href")));
const firstBox = await rows.first().boundingBox();
await page.getByRole("tab", { name: "Recent", exact: true }).click();
await expect(page.getByRole("tab", { name: "Recent", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(rows.first()).toBeVisible();
const start = performance.now();
await page.getByRole("tab", { name: "Mine", exact: true }).click();
await expect(page.getByRole("tab", { name: "Mine", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(rows.first()).toBeVisible();
const warm = performance.now() - start;
await page.locator("[data-inbox-issue-link]").first().click();
await expect(page.getByTestId("issue-detail-header")).toBeVisible();
const backStart = performance.now();
await page.goBack();
await expect(page.getByRole("tab", { name: "Mine", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(rows.first()).toBeVisible();
const back = performance.now() - backStart;
const resources = await page.evaluate(() => performance.getEntriesByType("resource").map(entry => {
const resource = entry as PerformanceResourceTiming;
const url = new URL(resource.name);
return { path: url.pathname, start: resource.startTime, ms: resource.duration, end: resource.responseEnd };
}).filter(resource => resource.path.startsWith("/api/") || resource.path.startsWith("/assets/")));
assert.deepEqual(errors, [], "The page reported JavaScript errors.");
expectedRowIds ??= rowIds;
assert.deepEqual(rowIds, expectedRowIds, "Mine row order or membership changed between samples.");
samples.push({ pair, arm, status: "passed", cold, warm, back, rowIds, rowSequenceEquivalent: true, firstBox, reads, resources, errors, load: loadavg() });
console.log(JSON.stringify({ pair, arm, cold, warm, back, rows: rowIds.length, errors }));
} catch (error) {
samples.push({ pair, arm, status: "failed", error: error instanceof Error ? error.message : String(error), rowIds, errors, reads, load: loadavg() });
throw error;
} finally {
await save();
await context.close();
}
}
}
} finally {
await save();
await browser?.close();
await Promise.all(Object.values(clients).map(client => client.dispose()));
}
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
let companyId: string | undefined;
test.afterEach(async ({ request }) => {
if (!companyId) return;
const response = await request.delete(`/api/companies/${companyId}`);
expect(response.ok(), await response.text()).toBeTruthy();
companyId = undefined;
});
test("Mine waits for missing parent metadata without loading parent diagnostics", async ({ page, request }) => {
const created = await request.post("/api/companies", { data: { name: "Parent context browser check" } });
expect(created.ok(), await created.text()).toBeTruthy();
const company = await created.json() as { id: string; issuePrefix: string };
companyId = company.id;
const preference = await request.put(`/api/companies/${company.id}/workspace-preferences/me`, {
data: { coordinatorWorkspaceEnabled: true },
});
expect(preference.ok(), await preference.text()).toBeTruthy();
const create = async (title: string, status: string, parentId?: string) => {
const response = await request.post(`/api/companies/${company.id}/issues`, { data: { title, status, parentId } });
expect(response.ok(), await response.text()).toBeTruthy();
return response.json() as Promise<{ id: string; identifier: string; title: string }>;
};
const loaded = await create("Indexed parent context", "blocked");
const missing = await create("Archived parent context", "done");
const first = await create("Child of indexed parent", "todo", loaded.id);
const second = await create("Child of archived parent", "todo", missing.id);
const archived = await request.post(`/api/issues/${missing.id}/inbox-archive`, { data: {} });
expect(archived.ok(), await archived.text()).toBeTruthy();
const reads: Array<{ projection: string | null; ids: string[] }> = [];
const contextRead = (url: URL) => url.pathname === `/api/companies/${company.id}/issues`
&& url.searchParams.get("view") === "index"
&& url.searchParams.get("ids")?.split(",").includes(missing.id) === true;
page.on("request", request => {
const url = new URL(request.url());
if (url.pathname === `/api/companies/${company.id}/issues` && url.searchParams.has("ids")) {
reads.push({ projection: url.searchParams.get("view"), ids: url.searchParams.get("ids")!.split(",") });
}
});
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
let releaseDetails!: () => void;
const detailsHeld = new Promise<void>(resolve => { releaseDetails = resolve; });
const detailRead = (url: URL) => url.pathname === `/api/companies/${company.id}/issues`
&& url.searchParams.get("view") === "rows"
&& url.searchParams.get("ids")?.split(",").includes(second.id) === true;
await page.route(detailRead, async route => { await detailsHeld; await route.continue(); });
await page.route(contextRead, async route => { await held; await route.continue(); });
const parentRequest = page.waitForRequest(request => contextRead(new URL(request.url())));
try {
await page.goto(`/${company.issuePrefix}/inbox/mine`);
await parentRequest;
expect(await page.locator("[data-inbox-item]").count()).toBe(0);
expect(reads.some(read => read.projection === "rows")).toBe(false);
expect(reads.some(read => read.ids.includes(loaded.id))).toBe(false);
} finally { release(); }
const child = page.locator(`[data-inbox-issue-link][href$="/${second.identifier}"]`);
await expect(child).toBeVisible();
const node = await child.elementHandle();
const row = child.locator("..");
// Visible task diagnostics remain deferred, and their completion must retain
// the title's node, position and row height.
await expect.poll(() => reads.some(read => read.projection === "rows" && read.ids.includes(second.id))).toBe(true);
const before = await row.boundingBox();
const detailsResponse = page.waitForResponse(response => detailRead(new URL(response.url())));
releaseDetails();
await (await detailsResponse).finished();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(page.locator(`[data-inbox-issue-link][href$="/${first.identifier}"]`)).toBeVisible();
expect(reads.filter(read => read.ids.includes(missing.id)).every(read => read.projection === "index")).toBe(true);
expect(await node!.evaluate(element => element.isConnected)).toBe(true);
const after = await row.boundingBox();
expect(Math.abs(before!.height - after!.height)).toBeLessThan(1);
expect(Math.abs(before!.y - after!.y)).toBeLessThan(1);
});
+203
View File
@@ -945,6 +945,209 @@ describe("Inbox toolbar", () => {
act(() => root.unmount());
});
it("loads only missing parent metadata before revealing Mine", async () => {
routerMock.location.pathname = "/inbox/mine";
localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({
version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {},
}));
const loadedParent = createIssue({ id: "loaded-parent", title: "Loaded parent", listProjection: "index" });
const missingParent = createIssue({ id: "missing-parent", title: "Missing parent", identifier: "PAP-2000", listProjection: "index" });
const rows = [loadedParent,
createIssue({ id: "child-a", parentId: loadedParent.id, title: "First child", listProjection: "index" }),
createIssue({ id: "child-b", parentId: missingParent.id, title: "Second child", listProjection: "index" }),
];
let releaseParent!: (rows: Issue[]) => void;
const pendingParent = new Promise<Issue[]>(resolve => { releaseParent = resolve; });
apiMocks.issuesList.mockImplementation((_company, filters) => {
if (!filters.ids) return Promise.resolve(rows);
if (filters.ids.includes(missingParent.id)) return pendingParent;
return Promise.resolve(rows.filter(row => filters.ids.includes(row.id)));
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.some(([, filters]) => filters.ids?.includes(missingParent.id))).toBe(true));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
ids: [missingParent.id], projection: "index",
}), expect.anything());
await act(() => releaseParent([missingParent]));
await vi.waitFor(() => expect(container.textContent).toContain("Second child"));
// Rich visible-row reads still happen after reveal, but never include a
// context-only parent or precede the structural metadata response.
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(missingParent.id))).toHaveLength(1);
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3);
expect(container.querySelector(`[title="${missingParent.title}"]`)?.textContent).toBe(missingParent.identifier);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("discards old parent context when a refreshed index supplies the parent", async () => {
routerMock.location.pathname = "/inbox/mine";
localStorage.setItem("paperclip:inbox:nesting", "false");
localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({
version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {},
}));
const parent = createIssue({ id: "parent", title: "Old parent caption", identifier: "PAP-2000", listProjection: "index" });
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
let source = [child];
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids
? Promise.resolve(source) : Promise.resolve(filters.ids.includes(parent.id) ? [parent] : [child]));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.querySelector(`[title="${parent.title}"]`)?.textContent).toBe(parent.identifier));
source = [{ ...parent, title: "Fresh indexed caption", identifier: "PAP-2001" }, child];
await act(() => { client.setQueriesData({ predicate: query => query.queryKey.includes("live-descendant-summary") }, source); });
await vi.waitFor(() => expect(container.querySelector('[title="Fresh indexed caption"]')?.textContent).toBe("PAP-2001"));
expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull();
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "index" && filters.ids?.includes(parent.id))).toHaveLength(1);
} finally { await act(() => root.unmount()); client.clear(); }
});
it.each([true, false])("waits for failure context even when its parent is already indexed=%s", async (indexed) => {
routerMock.location.pathname = "/inbox/mine";
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
apiMocks.heartbeatRunsList.mockResolvedValue([failure]);
let releaseFailure!: (rows: Issue[]) => void;
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids
? Promise.resolve(indexed ? [parent, child] : [child])
: filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child]));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
ids: [parent.id], projection: "rows",
}), expect.anything()));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids && filters.projection === "index")).toHaveLength(0);
await act(() => releaseFailure([{ ...parent, interruption: {
intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null,
canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
} }]));
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(indexed ? 2 : 1);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("upgrades cached parent metadata when the same task becomes a failure context", async () => {
routerMock.location.pathname = "/inbox/mine";
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
const resolved = { ...parent, listProjection: "rows" as const, interruption: {
intentId: "saved-input", responsibility: "system" as const, state: "resolved" as const,
nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
} };
let releaseFailure!: (rows: Issue[]) => void;
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
apiMocks.issuesList.mockImplementation((_company, filters) => {
if (!filters.ids) return Promise.resolve([child]);
if (filters.ids.includes(parent.id)) return filters.projection === "rows" ? pendingFailure : Promise.resolve([parent]);
return Promise.resolve([child]);
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
// The union of context IDs is unchanged when this parent becomes a failed
// task. It must still fetch interruption state instead of reusing index.
await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [failure]); });
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
ids: [parent.id], projection: "rows",
}), expect.anything()));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
await act(() => releaseFailure([resolved]));
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
expect(container.textContent).toContain(child.title);
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("reveals an empty view after leaving a Mine view with parent context", async () => {
routerMock.location.pathname = "/inbox/mine";
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" });
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids
? [parent] : filters.inboxArchivedByUserId ? [child] : []));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox tab="mine" /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
routerMock.location.pathname = "/inbox/recent";
await act(() => root.render(<QueryClientProvider client={client}><Inbox tab="recent" /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain("No recent inbox items."));
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
} finally { await act(() => root.unmount()); client.clear(); }
});
it("waits for rich failure state when heartbeat data arrives after parent captions", async () => {
routerMock.location.pathname = "/inbox/mine";
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" });
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
let releaseRuns!: (rows: HeartbeatRun[]) => void;
apiMocks.heartbeatRunsList.mockImplementation(() => new Promise(resolve => { releaseRuns = resolve; }));
let releaseFailure!: (rows: Issue[]) => void;
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child])
: filters.projection === "index" ? Promise.resolve([parent]) : filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child]));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(client.isFetching()).toBe(1));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
await act(() => releaseRuns([failure]));
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
ids: [parent.id], projection: "rows",
}), expect.anything()));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
await act(() => releaseFailure([{ ...parent, interruption: {
intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null,
canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
} }]));
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("keeps known missing-task failures visible while new failure context is pending", async () => {
routerMock.location.pathname = "/inbox/mine";
const child = createIssue({ title: "Readable task", listProjection: "index" });
const first = createFailedRun({ id: "failure-a", issueId: "missing-a", agentId: "agent-a", responsibleUserId: "local-board" });
const second = createFailedRun({ id: "failure-b", issueId: "missing-b", agentId: "agent-b", responsibleUserId: "local-board" });
apiMocks.heartbeatRunsList.mockResolvedValue([first]);
let releaseFailures!: (rows: Issue[]) => void;
const pendingFailures = new Promise<Issue[]>(resolve => { releaseFailures = resolve; });
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child])
: filters.ids.includes(second.issueId) ? pendingFailures : Promise.resolve([]));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-a"]')).not.toBeNull());
const node = container.querySelector('a[href="/issues/missing-a"]');
await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [first, second]); });
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
ids: [first.issueId, second.issueId], projection: "rows",
}), expect.anything()));
expect(container.querySelector('a[href="/issues/missing-a"]')).toBe(node);
expect(container.querySelector('a[href="/issues/missing-b"]')).toBeNull();
expect(container.textContent).toContain(child.title);
await act(() => releaseFailures([]));
await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-b"]')).not.toBeNull());
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("keeps more than 25 description-only matches in Mine with archive actions", async () => {
routerMock.location.pathname = "/inbox/mine";
const rows = Array.from({ length: 30 }, (_, index) => createIssue({ id: `source-${index}`, title: `Source task ${index}`, identifier: `PAP-${index}`, listProjection: "index" }));
+59 -21
View File
@@ -1062,25 +1062,60 @@ function StreamlinedInbox({
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
const contextIssueIds = useMemo(() => [...new Set([
...(tab === "mine" ? mineIssuesRaw : touchedIssuesRaw).map((issue) => issue.parentId),
...(heartbeatRuns ?? []).map(readIssueIdFromRun),
].filter((id): id is string => Boolean(id)))].sort(), [tab, mineIssuesRaw, touchedIssuesRaw, heartbeatRuns]);
const { data: contextIssues = [], isLoading: isContextIssuesLoading, error: contextIssuesError } = useQuery<Issue[]>({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "row-context", contextIssueIds],
placeholderData: (previous) => previous?.filter((issue) => issue.companyId === selectedCompanyId),
const { parentContextIds, failureContextIds, contextIssueIds } = useMemo(() => {
const sourceIssues = tab === "mine" ? mineIssuesRaw : touchedIssuesRaw;
const loadedIds = new Set(sourceIssues.map((issue) => issue.id));
// Failure cards need interruption state even when their task is already in
// the index. Parent captions only need index metadata.
const failureIds = new Set((heartbeatRuns ?? []).map(readIssueIdFromRun)
.filter((id): id is string => Boolean(id)));
const parentIds = new Set(sourceIssues.map((issue) => issue.parentId)
.filter((id): id is string => Boolean(id))
.filter((id) => !loadedIds.has(id) && !failureIds.has(id)));
return {
parentContextIds: [...parentIds].sort(),
failureContextIds: [...failureIds].sort(),
contextIssueIds: new Set([...parentIds, ...failureIds]),
};
}, [tab, mineIssuesRaw, touchedIssuesRaw, heartbeatRuns]);
const needsIssueContext = !!selectedCompanyId && contextIssueIds.size > 0 && tab !== "blocked";
const {
data: issueContext,
isLoading: isContextIssuesLoading,
isPlaceholderData: isContextIssuesPlaceholder,
error: contextIssuesError,
} = useQuery<{
companyId: string | null;
issues: Issue[];
failureIssueIds: string[];
}>({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "row-context", "parent-index-with-failure-state", parentContextIds, failureContextIds],
placeholderData: (previous) => needsIssueContext && previous?.companyId === selectedCompanyId
? { ...previous, issues: previous.issues.filter((issue) => contextIssueIds.has(issue.id)) }
: undefined,
queryFn: async ({ signal }) => {
const result: Issue[] = [];
for (let offset = 0; offset < contextIssueIds.length; offset += 100) {
result.push(...await issuesApi.listCompact(selectedCompanyId!, {
ids: contextIssueIds.slice(offset, offset + 100), projection: "rows", limit: 100,
includeRoutineExecutions: true,
}, { signal }) as Issue[]);
}
return result;
const loadContext = async (ids: string[], projection: "index" | "rows") => {
const result: Issue[] = [];
for (let offset = 0; offset < ids.length; offset += 100) {
result.push(...await issuesApi.listCompact(selectedCompanyId!, {
ids: ids.slice(offset, offset + 100), projection, limit: 100,
includeRoutineExecutions: true,
}, { signal }) as Issue[]);
}
return result;
};
const [parents, failures] = await Promise.all([
loadContext(parentContextIds, "index"),
loadContext(failureContextIds, "rows"),
]);
// A completed failure read also establishes absence for filtered/missing
// tasks. Parent-only placeholders cannot supply this coverage.
return { companyId: selectedCompanyId, issues: [...parents, ...failures], failureIssueIds: failureContextIds };
},
enabled: !!selectedCompanyId && contextIssueIds.length > 0 && tab !== "blocked",
enabled: needsIssueContext,
});
const contextIssues = issueContext?.issues ?? [];
const knownFailureContextIds = useMemo(() => new Set(issueContext?.failureIssueIds ?? []), [issueContext]);
const issues = useMemo(() => [...mineIssuesRaw, ...touchedIssuesRaw, ...contextIssues], [mineIssuesRaw, touchedIssuesRaw, contextIssues]);
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
@@ -1379,11 +1414,13 @@ function StreamlinedInbox({
const failedRuns = useMemo(
() =>
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter(
(r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt)
&& issueById.get(readIssueIdFromRun(r) ?? "")?.interruption?.state !== "resolved",
),
[heartbeatRuns, dismissedAtByKey, issueById],
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((run) => {
const issueId = readIssueIdFromRun(run);
return !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt)
&& (!issueId || knownFailureContextIds.has(issueId) || !!contextIssuesError)
&& issueById.get(issueId ?? "")?.interruption?.state !== "resolved";
}),
[heartbeatRuns, dismissedAtByKey, issueById, knownFailureContextIds, contextIssuesError],
);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
@@ -2464,6 +2501,7 @@ function StreamlinedInbox({
!isApprovalsLoading &&
!isDashboardLoading &&
!isContextIssuesLoading &&
!isContextIssuesPlaceholder &&
!isMineIssuesLoading &&
!isTouchedIssuesLoading &&
!isRunsLoading