diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index e6904ca8d2e..eb11c0d5f85 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -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 diff --git a/tests/perf/collections/README.md b/tests/perf/collections/README.md index eda8de20f4c..c88a05532ac 100644 --- a/tests/perf/collections/README.md +++ b/tests/perf/collections/README.md @@ -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: \ +INBOX_CANDIDATE_URL=http://127.0.0.1: \ + 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 diff --git a/tests/perf/collections/measure-mine-context.ts b/tests/perf/collections/measure-mine-context.ts new file mode 100644 index 00000000000..36108383dee --- /dev/null +++ b/tests/perf/collections/measure-mine-context.ts @@ -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 { + 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> = []; +let expectedRowIds: Array | undefined; +let fixture: Fixture | undefined; +let browser: Awaited> | 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 | 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())); +} diff --git a/tests/perf/collections/parent-context.spec.ts b/tests/perf/collections/parent-context.spec.ts new file mode 100644 index 00000000000..a7065d340da --- /dev/null +++ b/tests/perf/collections/parent-context.spec.ts @@ -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(resolve => { release = resolve; }); + let releaseDetails!: () => void; + const detailsHeld = new Promise(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); +}); diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 1cb9f35f953..72656aa8361 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -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(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()); + 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()); + 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(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()); + 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(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()); + 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()); + await vi.waitFor(() => expect(container.textContent).toContain(child.title)); + routerMock.location.pathname = "/inbox/recent"; + await act(() => root.render()); + 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(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()); + 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(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()); + 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" })); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 39a842f0e0a..d2ec65baa74 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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({ - 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