diff --git a/doc/plans/2026-10-26-list-cold-open.md b/doc/plans/2026-10-26-list-cold-open.md new file mode 100644 index 00000000000..b52dfd2fd70 --- /dev/null +++ b/doc/plans/2026-10-26-list-cold-open.md @@ -0,0 +1,99 @@ +# Task list and inbox cold opens + +## Outcome and ownership + +Cold-open `/issues` and `/inbox/*` should show useful, correctly ordered rows +without inserting earlier rows or changing row geometry as supporting data arrives. +This work owns the collection read contracts, their UI consumers, and navigation +badge reads. Keep issue authorization, mutation semantics, archive/undo, and +existing grouping/filter preferences with their current owners. The separate +staging performance effort (PR #17745) owns runner event-loop stalls. + +Branch: `codex/list-cold-open`, rebased onto `063a498b54`. +Endpoint: tested, reviewable PR with checks and review resolved; local test drive. +No staging infrastructure changes are needed for this implementation. + +## Evidence and acceptance + +Baseline on staging, authenticated browser with HTTP cache retained and fresh +application state: task rows ~4.5s; Mine task rows ~6.3s. Mine downloaded four +overlapping issue responses (500, 167, 52, 52 rows; 2,388,103 decoded bytes). +A failed-run row appeared at y=183 before tasks arrived, then moved to y=415. +These are individual observations, not percentile benchmarks. + +| Journey | Required result | Evidence | +| --- | --- | --- | +| Cold Tasks / Mine | Bounded initial data; final row order before reveal; metadata fills stable slots | Request sizes, delayed-response browser trace, row geometry | +| Other inbox tabs and saved filters/grouping/nesting | Correct membership/order before paging, no false empty state | Focused contract/component tests plus local browser | +| More results and detail/back | No lost or duplicated rows; reading position preserved | Browser walkthrough and paging regressions | +| Archive/undo/read and background activity | Existing optimistic behavior survives; observed rows stay put | Existing regression suites and browser | +| Slow/failed metadata or list read | Stable layout; useful retry/error; no misleading partial inbox | Deferred request component tests and browser | +| Company/account boundaries | No cross-company or private-row leaks in lists or badges | Authorization tests | + +## Implementation sequence + +1. Remove full-list badge reads; preserve personal unread/archive/dismissal semantics. +2. Add a lean ordering index using existing pagination/filter contracts, then enrich + only visible rows (at most 50). Batch hierarchy reads rather than fetching full + task bodies per parent. Mine selects its most recent 100 rows server-side; + existing client grouping/filtering remains in place. This is not a new global + server-side pagination model for every saved view. +3. Commit inbox source data together rather than revealing unrelated responses in arrival order. +4. Match skeleton and row geometry. Enrichment cannot replace index membership, + sorting fields, or row geometry; retain the existing inbox order pin. +5. Preload the requested collection module during access/company checks and stop + downloading task detail code eagerly on collection opens. +6. Verify in the existing isolated local app, run required checks, prepare PR and resolve review. + +Use targeted tests while iterating; full repository verification before PR-ready handoff. +Do not infer deployed speedup from local measurements or combine the separate server fix's savings. + +## State + +Implemented the bounded index, visible-row enrichment, compact badge reads, and +initial snapshot barrier. Legacy task lists retain their existing compact data. +Index and row requests retain ETags and in-flight coalescing but skip the short +server response TTL, so an archive undo immediately reads the changed state. + +Local browser proof uses 160 seeded tasks, nested rows, long titles, unread +activity, and a saved recovery action. On desktop, the first Tasks placeholder +and task both start at y=163 with height 44. The equivalent inbox values are +y=175 and height 44. Delayed row enrichment produced zero position, height, or +title-width change across 12 measured rows on each page. Narrow-screen review +at 390 by 844 confirmed truncation and a compact recovery indicator. + +Three fresh document loads with retained HTTP cache and a warm local Vite server +showed Tasks in 2.054–2.170s and Mine in 2.115–2.268s after the archive/undo fix. These include browser-control +overhead while the test suite ran on the same computer. They are not comparable +to staging latency. For the same 100 local tasks, the old compact response was +262,441 decoded bytes and the index was 110,909 bytes, with no descriptions. + +Workspace typecheck, production build, token gates, map validation, and focused +UI/server regressions passed. The initial full local run spanned a rebase and was +stopped after reporting failures; a clean serial rerun of all five affected +suites passed 452 tests. Fresh current-commit CI is the full-suite acceptance gate. + +Review fixes preserve description-only inbox matches through a scoped, on-demand +index search and keep unread task IDs board-only. Collapsed parents use a batched +one-child preview instead of spending the expanded row budget or making a request +per parent. Optional-detail failures show a retry notice outside document flow; +12 measured rows remained unchanged and retry restored the recovery indicator. +All seven exact-run retry/legacy continuation browser scenarios passed after +fixing the company-loading hook order. Archive/Undo, read/back, nesting, remote +search and rendering all 164 fixture tasks without duplicates also passed. +PR: https://github.com/paperclipai/paperclip/pull/17756. +Browser evidence is retained in the chat's `list-cold-open-test-drive` artifact +directory. Existing task-detail PR #17593 is already merged and verified on +staging; its paused hourly monitor is not part of this change. + +The final review pass accepts newer diagnostic snapshots while retaining index +ordering, and refreshes visible diagnostics when a task version changes. Optional +inbox source failures keep available rows and expose retry outside document flow; +required external-status filter failures show an error rather than an endless +skeleton. A browser-injected failed run source retained 12 rows at the same +positions and heights before, during, and after retry. The initial hierarchy +barrier remains intentional to avoid inserting ancestors or children above rows +already being read; subsequent hierarchy requests retain the previous context. +Server route coverage now verifies latest-failure filtering, bounded windows, +agent filters, and private-run redaction. The unused client badge implementation +and its obsolete tests were removed; the production server badge tests remain. diff --git a/docs/api/issues.md b/docs/api/issues.md index f1e0ab0d6eb..272bd18e67a 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -18,9 +18,19 @@ Query parameters: | `status` | Filter by status (comma-separated: `todo,in_progress`) | | `assigneeAgentId` | Filter by assigned agent | | `projectId` | Filter by project | +| `view` | `index`: collection rows without bodies or diagnostics; `rows`: rows with diagnostic summaries but no bodies; `compact`: existing board row contract | +| `ids` | One to 100 comma-separated task UUIDs; requires `view=index` or `view=rows` without `attention` | +| `parentIds` | One to 100 comma-separated parent UUIDs for batched child context; same projection restriction as `ids` | +| `oneChildPerParent` | With `view=index` and `parentIds`, return at most one readable child per parent for collapsed-tree affordances | Results sorted by priority. +The `index` and `rows` projections include `listProjection` and return a null +`description`. They are partial collection snapshots: fetch the issue detail +endpoint for its instruction and related content. Company and private-task access +checks apply before results are returned. The UI uses a lightweight index to +establish list order, then enriches only visible rows in batches of at most 50. + ## Get Issue ``` diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md index 565b1764c60..3ddc784c916 100644 --- a/maps/agent-access-map/inbox-search.md +++ b/maps/agent-access-map/inbox-search.md @@ -23,6 +23,13 @@ visibility exception. Company/private-task access checks still apply. Collection preferences and hierarchy presentation are browser state, not additional authority. Source-reviewed behavior; live agent access verification remains separate. +Collection projections `view=index` and `view=rows` use the same issue-list +company/private-task checks. Optional `ids` and `parentIds` batches accept at most +100 UUIDs and cannot be combined with blocked attention. They grant no new access. +`listProjection` marks omitted task bodies so clients do not reuse them as detail. +The sidebar count endpoint returns only readable unread Mine task IDs; failed-run +counts apply the run read decision. No new agent execution or mutation path is added. + Review: `baseline`. API: `present` — representative operation IDs: diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 5c0e4af53c8..3db27c21240 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -67,6 +67,15 @@ participation, while the cleanup remains in activity history. Record an explicit user recovery decision on a separate task and verify it still counts. This browser journey remains manual. +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. +The [Inbox component checks](../../ui/src/pages/Inbox.test.tsx) cover the source +barrier and selected-source query; [badge tests](../../server/src/__tests__/inbox-dismissals.test.ts) +cover unread counts. Browser timing and layout proof remain separate from these tests. + ### `decisions` Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 35599105a4b..094f5dd5f11 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -29,6 +29,13 @@ separate. It does not cover other property operations or agent authorization. ## How to get to it (user POV) +Cold collection loads use a lightweight index and batched hierarchy context, +then fetch diagnostic details for visible rows. Delay the index, hierarchy, and +visible-row details independently when verifying `tasks/organize`: row heights, +title bounds, and the first-row origin should remain stable. Test nested expansion, +scrolling to more rows, and detail/back navigation; a collection projection must +never suppress the full task instruction fetch. + ### `new-task` Use New task from the global launcher, task list, or project task view. diff --git a/packages/shared/src/heartbeat-inbox.ts b/packages/shared/src/heartbeat-inbox.ts index 256a5c0bb82..227dc89705c 100644 --- a/packages/shared/src/heartbeat-inbox.ts +++ b/packages/shared/src/heartbeat-inbox.ts @@ -8,3 +8,12 @@ export function isHeartbeatRunVisibleInMine( // Preserve unattributed historical runs for the single-user local board. return currentUserId === "local-board"; } + +/** The inbox lists failures only when the agent's latest observed run failed. */ +export function latestFailedRunsByAgent(runs: readonly T[]): T[] { + const latest = new Map(); + for (const run of [...runs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())) { + if (!latest.has(run.agentId)) latest.set(run.agentId, run); + } + return [...latest.values()].filter((run) => run.status === "failed" || run.status === "timed_out"); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5ad42e5a7a1..8ed0a8c160c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2795,7 +2795,7 @@ export { restoreAgentInstructionSchema } from "./validators/agent.js"; export type { AgentInstructionCandidate } from "./types/agent.js"; export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js"; -export { isHeartbeatRunVisibleInMine } from "./heartbeat-inbox.js"; +export { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent } from "./heartbeat-inbox.js"; export * from "./browser-use.js"; export * from "./types/skill-source.js"; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index cb6b7a5b622..68a03628e82 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -843,6 +843,8 @@ export type IssueSummary = Pick< }; export interface Issue { + /** Collection projections omit task bodies and cannot seed the detail cache. */ + listProjection?: "index" | "rows"; statusVersion?: number; /** True only while the title is the provisional slice of the initial prompt. */ titleNeedsGeneration?: boolean; @@ -958,6 +960,7 @@ export interface Issue { export type CompactIssue = Pick< Issue, + | "listProjection" | "externalConversationState" | "id" | "companyId" diff --git a/packages/shared/src/types/sidebar-badges.ts b/packages/shared/src/types/sidebar-badges.ts index 0a3b767b370..d1ba9a64271 100644 --- a/packages/shared/src/types/sidebar-badges.ts +++ b/packages/shared/src/types/sidebar-badges.ts @@ -3,4 +3,6 @@ export interface SidebarBadges { approvals: number; failedRuns: number; joinRequests: number; + /** Visible unread Mine tasks, retained so optimistic archive can adjust the badge. */ + unreadIssueIds?: string[]; } diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index ed10d2548f2..6aaa6a7d84d 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -328,6 +328,37 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, expect(maintenance.body.resultJson.summary).toBe("Maintenance complete"); }); + it("filters latest failures within the requested run window and preserves private-run redaction", async () => { + const f = await seedFixture(); + await db.update(heartbeatRuns).set({ status: "failed", createdAt: new Date("2026-10-26T10:00:00Z") }) + .where(eq(heartbeatRuns.id, f.privateRunId)); + await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T09:00:00Z") }) + .where(eq(heartbeatRuns.id, f.maintenanceRunId)); + const timeoutId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: timeoutId, companyId: f.companyId, agentId: f.otherAgentId, scopeKind: "company", + invocationSource: "timer", status: "timed_out", createdAt: new Date("2026-10-26T11:00:00Z"), + }); + const app = createApp(f.companyId, f.otherAgentId); + const path = `/api/companies/${f.companyId}/heartbeat-runs?summary=true&latestFailuresOnly=true`; + const response = await request(app).get(path).expect(200); + expect(response.body.map((run: { id: string }) => run.id)).toEqual([timeoutId, f.privateRunId]); + const privateStub = response.body[1]; + expect(privateStub).toMatchObject({ redacted: true, status: "failed" }); + expect(privateStub).not.toHaveProperty("issueId"); + expect(privateStub).not.toHaveProperty("contextSnapshot"); + expect(JSON.stringify(response.body)).not.toContain("Confidential email triage"); + const bounded = await request(app).get(`${path}&limit=1`).expect(200); + expect(bounded.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]); + const agentOnly = await request(app).get(`${path}&agentId=${f.ownerAgentId}`).expect(200); + expect(agentOnly.body.map((run: { id: string }) => run.id)).toEqual([f.privateRunId]); + // A newer success suppresses an older failure rather than selecting the latest failure alone. + await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T12:00:00Z") }) + .where(eq(heartbeatRuns.id, f.maintenanceRunId)); + const recovered = await request(app).get(path).expect(200); + expect(recovered.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]); + }); + it("returns full private-run content to the owner and an explicitly granted agent", async () => { const fixture = await seedFixture(); diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts index 937d8b7cc88..b5ae5eea411 100644 --- a/server/src/__tests__/inbox-dismissals.test.ts +++ b/server/src/__tests__/inbox-dismissals.test.ts @@ -11,6 +11,9 @@ import { heartbeatRuns, inboxDismissals, invites, + issues, + issueComments, + issueReadStates, joinRequests, } from "@paperclipai/db"; import { @@ -22,6 +25,7 @@ import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js"; import { inboxDismissalService } from "../services/inbox-dismissals.ts"; import { sidebarBadgeRoutes } from "../routes/sidebar-badges.js"; import { sidebarBadgeService } from "../services/sidebar-badges.ts"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -46,6 +50,9 @@ describeEmbeddedPostgres("inbox dismissals", () => { }, 20_000); afterEach(async () => { + await db.delete(issueReadStates); + await db.delete(issueComments); + await db.delete(issues); await db.delete(inboxDismissals); await db.delete(joinRequests); await db.delete(invites); @@ -100,6 +107,49 @@ describeEmbeddedPostgres("inbox dismissals", () => { expect(response.body.inbox).toBe(expected); }); + it("counts only unread Mine tasks without returning their bodies", async () => { + const companyId = randomUUID(), issueId = randomUUID(), otherId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Badge fixture", issuePrefix: "BADGE" }); + const createdAt = new Date("2026-03-11T01:00:00Z"); + const replyAt = new Date("2026-03-11T02:00:00Z"); + await db.insert(issues).values([ + { id: issueId, companyId, title: "Mine", description: "Large body".repeat(10000), createdByUserId: "local-board", createdAt }, + { id: otherId, companyId, title: "Not mine", createdByUserId: "someone-else", createdAt }, + ]); + await db.insert(issueComments).values([issueId, otherId].map((id) => ({ companyId, issueId: id, authorType: "user" as const, authorUserId: "someone-else", body: "Reply", createdAt: replyAt }))); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "local_implicit", userId: "local-board", companyIds: [companyId], isInstanceAdmin: true }; + next(); + }); + app.use("/api", sidebarBadgeRoutes(db)); + app.use(errorHandler); + const response = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200); + expect(response.body).toMatchObject({ inbox: 1, unreadIssueIds: [issueId] }); + expect(JSON.stringify(response.body).length).toBeLessThan(300); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, companyId, name: "Restricted reviewer", role: "engineer", + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, rootIssueId: otherId, projectIds: [] } }, + }, + }); + const agentApp = express(); + agentApp.use((req, _res, next) => { + req.actor = { type: "agent", source: "agent_jwt", agentId, companyId, onBehalfOfUserId: "local-board", + onBehalfOfMemberships: [{ companyId, membershipRole: "member", status: "active" }] }; + next(); + }); + agentApp.use("/api", sidebarBadgeRoutes(db)); + agentApp.use(errorHandler); + const agentResponse = await request(agentApp).get(`/api/companies/${companyId}/sidebar-badges`).expect(200); + expect(agentResponse.body).toMatchObject({ inbox: 0, unreadIssueIds: [] }); + await db.insert(issueReadStates).values({ companyId, issueId, userId: "local-board", lastReadAt: replyAt }); + const read = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200); + expect(read.body).toMatchObject({ inbox: 0, unreadIssueIds: [] }); + }); + it("upserts a single dismissal record per user and inbox item key", async () => { const companyId = randomUUID(); const userId = "board-user"; diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index 3c685dcfa3d..e2ab63d853a 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -97,6 +97,80 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { }); } + it.each(["index", "rows"])("revalidates %s rows immediately after a mutation", async (view) => { + const companyId = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Fresh list", issuePrefix: uniqueIssuePrefix() }); + await seedCloudTenantMember(companyId); + await db.insert(issues).values({ id: issueId, companyId, title: "Before" }); + const app = createApp(companyId); + const before = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId }).expect(200); + await db.update(issues).set({ title: "After" }).where(eq(issues.id, issueId)); + const after = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId }) + .set("If-None-Match", before.headers.etag).expect(200); + expect(after.body[0].title).toBe("After"); + expect(after.headers.etag).not.toBe(before.headers.etag); + expect(after.headers["x-paperclip-request-cache"]).toBe("miss"); + }); + + it("keeps index and row batches scoped to readable tasks and validates their bounds", async () => { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); + try { + const companyId = randomUUID(), foreignCompanyId = randomUUID(); + const parentId = randomUUID(), childId = randomUUID(), privateId = randomUUID(), foreignId = randomUUID(); + await db.insert(companies).values([ + { id: companyId, name: "Lists", issuePrefix: uniqueIssuePrefix() }, + { id: foreignCompanyId, name: "Other", issuePrefix: uniqueIssuePrefix() }, + ]); + await seedCloudTenantMember(companyId); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Public parent" }, + { id: childId, companyId, parentId, title: "Public child" }, + { id: privateId, companyId, parentId, title: "Private child", visibility: "private", privacyRootIssueId: privateId, createdByUserId: "someone-else" }, + { id: foreignId, companyId: foreignCompanyId, title: "Foreign task" }, + ]); + const app = createApp(companyId); + for (const view of ["index", "rows"]) { + const selected = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: [parentId, privateId, foreignId].join(",") }); + expect(selected.status, JSON.stringify(selected.body)).toBe(200); + expect(selected.body.map((row: { id: string }) => row.id)).toEqual([parentId]); + const children = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, parentIds: parentId }); + expect(children.status).toBe(200); + expect(children.body.map((row: { id: string }) => row.id)).toEqual([childId]); + } + const preview = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", parentIds: parentId, oneChildPerParent: true }).expect(200); + expect(preview.body.map((row: { id: string }) => row.id)).toEqual([childId]); + for (const query of [ + { view: "index", ids: "invalid" }, + { view: "index", parentIds: Array.from({ length: 101 }, () => randomUUID()).join(",") }, + { view: "index", ids: parentId, attention: "blocked" }, + { view: "compact", ids: parentId }, + { view: "index", oneChildPerParent: true }, + { view: "rows", parentIds: parentId, oneChildPerParent: true }, + { view: "index", parentIds: parentId, oneChildPerParent: "invalid" }, + ]) { + expect((await request(app).get(`/api/companies/${companyId}/issues`).query(query)).status).toBe(400); + } + } finally { vi.unstubAllEnvs(); } + }); + + it("returns one child per parent without letting a large collapsed tree fill the page", async () => { + const companyId = randomUUID(), firstParentId = randomUUID(), secondParentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Collapsed trees", issuePrefix: uniqueIssuePrefix() }); + await seedCloudTenantMember(companyId); + await db.insert(issues).values([ + { id: firstParentId, companyId, title: "First parent" }, + { id: secondParentId, companyId, title: "Second parent" }, + ]); + await db.insert(issues).values([ + ...Array.from({ length: 120 }, (_, index) => ({ companyId, parentId: firstParentId, title: `Child ${index}` })), + { companyId, parentId: secondParentId, title: "Other child" }, + ]); + const response = await request(createApp(companyId)).get(`/api/companies/${companyId}/issues`) + .query({ view: "index", parentIds: [firstParentId, secondParentId].join(","), oneChildPerParent: true, limit: 2 }).expect(200); + expect(response.body).toHaveLength(2); + expect(new Set(response.body.map((row: { parentId: string }) => row.parentId))).toEqual(new Set([firstParentId, secondParentId])); + }); + it("returns only unassigned issues for assigneeAgentId=null", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); @@ -240,6 +314,15 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { assigneeAgentId: ownerAgentId, }, }); + const index = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", limit: "20" }); + expect(index.status).toBe(200); + expect(index.body).toHaveLength(1); + expect(index.body[0]).toMatchObject({ id: issueId, listProjection: "index", description: null, activeRecoveryAction: null, successfulRunHandoff: null }); + expect(index.headers.etag).not.toBe(res.headers.etag); + const detailRows = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "rows", ids: issueId }); + expect(detailRows.status).toBe(200); + expect(detailRows.body[0]).toMatchObject({ id: issueId, listProjection: "rows", description: null, activeRecoveryAction: { id: recoveryAction.id } }); + expect(res.body[0]).not.toHaveProperty("workProducts"); expect(res.body[0]).not.toHaveProperty("project"); expect(res.body[0]).not.toHaveProperty("goal"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7351ab8fb20..4d30041567b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { latestFailedRunsByAgent } from "@paperclipai/shared"; import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js"; import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js"; import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; @@ -6910,7 +6911,8 @@ export function agentRoutes( ? Math.max(1, Math.min(HEARTBEAT_RUN_LIST_MAX_LIMIT, parseInt(limitParam, 10) || HEARTBEAT_RUN_LIST_DEFAULT_LIMIT)) : HEARTBEAT_RUN_LIST_DEFAULT_LIMIT; const summary = req.query.summary === "true" || req.query.summary === "1"; - const runs = await heartbeat.list(companyId, agentId, limit, { summary }); + const recentRuns = await heartbeat.list(companyId, agentId, limit, { summary }); + const runs = req.query.latestFailuresOnly === "true" ? latestFailedRunsByAgent(recentRuns) : recentRuns; const readDecisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs); res.json(await runRedactions.redactForRuns(companyId, runs.map((run, index) => readDecisions[index] ? run : redactHeartbeatRunListRow(run)), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 842df00a10d..2035e1099da 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8354,7 +8354,26 @@ export function issueRoutes( const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; const view = req.query.view as string | undefined; - const compactView = view === "compact"; + const compactView = view === "compact" || view === "index" || view === "rows"; + const issueIds = typeof req.query.ids === "string" ? [...new Set(req.query.ids.split(","))] : undefined; + const parentIds = typeof req.query.parentIds === "string" ? [...new Set(req.query.parentIds.split(","))] : undefined; + const oneChildPerParent = parseOptionalBooleanQuery(req.query.oneChildPerParent); + if (oneChildPerParent === null || (oneChildPerParent && (view !== "index" || !parentIds))) { + res.status(400).json({ error: "oneChildPerParent must be a boolean and requires view=index with parentIds" }); + return; + } + if (req.query.ids !== undefined && (!issueIds?.length || issueIds.length > 100 || issueIds.some((id) => !z.string().uuid().safeParse(id).success))) { + res.status(400).json({ error: "ids must contain one to 100 comma-separated UUIDs" }); + return; + } + if (req.query.parentIds !== undefined && (!parentIds?.length || parentIds.length > 100 || parentIds.some((id) => !z.string().uuid().safeParse(id).success))) { + res.status(400).json({ error: "parentIds must contain one to 100 comma-separated UUIDs" }); + return; + } + if ((issueIds || parentIds) && (attention || (view !== "index" && view !== "rows"))) { + res.status(400).json({ error: "ids and parentIds require view=index or view=rows without attention" }); + return; + } const hasPlanDocument = parseOptionalBooleanQuery( req.query.hasPlanDocument, ); @@ -8421,8 +8440,8 @@ export function issueRoutes( .json({ error: "attention must be 'blocked' when provided" }); return; } - if (view !== undefined && view !== "compact") { - res.status(400).json({ error: "view must be 'compact' when provided" }); + if (view !== undefined && !["compact", "index", "rows"].includes(view)) { + res.status(400).json({ error: "view must be compact, index, or rows when provided" }); return; } if ( @@ -8509,6 +8528,10 @@ export function issueRoutes( const offset = parsedOffset ?? 0; const listFilters: IssueFilters = { + issueIds, + parentIds, + oneChildPerParent, + listProjection: view === "index" || view === "rows" ? view : undefined, readCondition: await issueReadSqlCondition(db, req.actor), attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, @@ -8564,14 +8587,16 @@ export function issueRoutes( companyId, normalizedQuery: { ...requestKeyFilters, - view: compactView ? "compact" : undefined, + view, }, }); const coordinated = await coordinateIssueListGet({ req, companyId, requestKey, - allowTtlCache: compactView, + // The new collection projections must reflect read/archive/undo mutations + // immediately. Keep in-flight coalescing and ETags, without a stale TTL body. + allowTtlCache: view === "compact", diagnostics: opts.issueListDiagnostics, compute: async () => { const rawResult = await svc.list(companyId, listFilters); @@ -8579,6 +8604,10 @@ export function issueRoutes( ? rawResult : await filterIssuesForActor(req, rawResult); const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows); + if (view === "index") { + const body = result.map((issue) => ({ ...toCompactIssue(issue), listProjection: "index" as const })); + return { kind: "compact", body, etag: compactIssueListEtag(body), cacheControl: "private, must-revalidate" }; + } const issueIds = result.map((issue) => issue.id); const interruptions = await taskInterruptionPresentations(db, companyId, result); if (compactView) { @@ -8603,12 +8632,12 @@ export function issueRoutes( }), ); const compactResult = result.map((issue) => - toCompactIssue({ + ({ ...toCompactIssue({ ...issue, activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, interruption: interruptions.get(issue.id) ?? null, successfulRunHandoff: handoffStates.get(issue.id) ?? null, - }), + }), ...(view === "rows" ? { listProjection: "rows" as const } : {}) }), ); return { kind: "compact", diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 3946cac4d74..c29781e352a 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4269,10 +4269,15 @@ registry.registerPath({ tags: ["issues"], summary: "List issues in a company", description: - "Use `view=compact` for the board issue-list row contract. The default response remains the broad compatibility contract.", + "Use view=index for collection membership/order without task bodies or diagnostic enrichment; view=rows enriches visible rows without task bodies. These responses include listProjection and cannot stand in for a full issue. view=compact retains the board row compatibility contract. The default response remains the broad compatibility contract. All projections enforce company and task access.", request: { params: z.object({ companyId: z.string() }), - query: z.object({ view: z.enum(["compact"]).optional() }).passthrough(), + query: z.object({ + view: z.enum(["compact", "index", "rows"]).optional(), + ids: z.string().optional().describe("One to 100 comma-separated task UUIDs; requires index or rows without attention."), + parentIds: z.string().optional().describe("One to 100 comma-separated parent UUIDs; requires index or rows without attention."), + oneChildPerParent: z.boolean().optional().describe("Return at most one readable child per parent; requires view=index and parentIds."), + }).passthrough(), }, responses: { 200: r.ok(), @@ -7477,6 +7482,7 @@ registry.registerPath({ agentId: z.string().optional(), limit: z.coerce.number().int().min(1).max(1000).default(200), summary: z.enum(["true", "false", "1", "0"]).optional(), + latestFailuresOnly: z.enum(["true", "false"]).optional().describe("Return failures only when the agent's latest run within the bounded result failed or timed out. Applied before read redaction."), }), }, responses: { 200: r.ok(), 401: r.unauthorized }, diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index d2d20c956b4..e7f91fc4374 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -6,6 +6,8 @@ import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; +import { issueService } from "../services/issues.js"; +import { approvalReadSqlCondition, issueReadSqlCondition } from "../services/authorization.js"; function buildDismissedAtByKey( dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>, @@ -27,6 +29,7 @@ export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); const access = accessService(db); + const issues = issueService(db); router.get("/companies/:companyId/sidebar-badges", async (req, res) => { const companyId = req.params.companyId as string; @@ -77,15 +80,24 @@ export function sidebarBadgeRoutes(db: Db) { : new Map(); // Company health alerts belong in All, not the personal inbox badge. + // These IDs reconcile optimistic archives in the board UI. An agent's + // responsible user does not grant it access to that user's task collection. + const currentUserId = req.actor.type === "board" ? req.actor.userId ?? null : null; + const unreadIssueIds = currentUserId + ? await issues.unreadInboxIssueIds(companyId, currentUserId, await issueReadSqlCondition(db, req.actor)) + : []; const badges = await svc.get(companyId, { currentUserId: req.actor.type === "board" ? req.actor.userId ?? null : req.actor.onBehalfOfUserId ?? null, dismissals: dismissedAtByKey, joinRequests: visibleJoinRequests, + unreadTouchedIssues: unreadIssueIds.length, + approvalReadCondition: await approvalReadSqlCondition(db, req.actor), + actor: req.actor, }); - res.json(badges); + res.json({ ...badges, unreadIssueIds }); }); return router; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 7e820583f18..d413d0304cf 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1876,6 +1876,10 @@ export interface IssueFilters { */ assigneeAgentId?: string | null; participantAgentId?: string; + issueIds?: string[]; + listProjection?: "index" | "rows"; + parentIds?: string[]; + oneChildPerParent?: boolean; assigneeUserId?: string; touchedByUserId?: string; inboxArchivedByUserId?: string; @@ -8038,6 +8042,32 @@ export function issueService(db: Db) { clearCheckoutRunIfTerminal, addStopRelayCommentIfNeeded, + unreadInboxIssueIds: async (companyId: string, userId: string, readCondition: SQL) => { + // Rank the visible Mine set before counting unread, just like the inbox. + // This projection never reads descriptions, run payloads, or recovery details. + const rows = await db.select({ + id: issues.id, + }).from(issues).where(and( + eq(issues.companyId, companyId), + visibleIssueCondition(), + isNull(issues.conversationAgentId), + nonPluginOperationIssueCondition(), + ne(issues.status, "cancelled"), + touchedByUserCondition(companyId, userId), + inboxVisibleForUserCondition(companyId, userId), + readCondition, + )).orderBy(desc(issueCanonicalLastActivityAtExpr(companyId)), desc(issues.updatedAt), desc(issues.id)).limit(100); + if (rows.length === 0) return []; + // Keep correlated predicates in WHERE (as in the list's user stats). + // A single-table Drizzle SELECT projection unqualifies nested columns. + return db.select({ id: issues.id }).from(issues).where(and( + eq(issues.companyId, companyId), + inArray(issues.id, rows.map((row) => row.id)), + unreadForUserCondition(companyId, userId), + readCondition, + )).then((unread) => unread.map((row) => row.id)); + }, + list: async (companyId: string, filters?: IssueFilters) => { if (filters?.sortField === "id" && filters.attention) { throw unprocessable("ID ordering is not supported for blocked attention lists"); @@ -8060,7 +8090,15 @@ export function issueService(db: Db) { eq(issues.companyId, companyId), visibleIssueCondition(), ]; - if (!filters?.q?.trim()) { + if (filters?.issueIds) { + if (filters.issueIds.length === 0) return []; + conditions.push(inArray(issues.id, filters.issueIds)); + } + if (filters?.parentIds) { + if (filters.parentIds.length === 0) return []; + conditions.push(inArray(issues.parentId, filters.parentIds)); + } + if (!filters?.q?.trim() && !filters?.issueIds) { conditions.push(isNull(issues.conversationAgentId)); if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) { conditions.push(nonIdleSlackIssueCondition()); @@ -8218,7 +8256,14 @@ export function issueService(db: Db) { } const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; const searchOrder = sql`-task_search.score`; - const issueSource = db.select(issueListSelect).from(issues); + const selectedFields = filters?.listProjection ? { + ...issueListSelect, + description: sql`NULL`, + descriptionTruncated: sql`false`, + } : issueListSelect; + const issueSource = (filters?.oneChildPerParent + ? db.selectDistinctOn([issues.parentId], selectedFields) + : db.select(selectedFields)).from(issues); const searchedSource = hasSearch ? issueSource.innerJoin(sql`( ${taskSearchCtes(companyId, taskSearch, true, and(...conditions), filters?.readCondition)} @@ -8228,6 +8273,7 @@ export function issueService(db: Db) { const baseQuery = searchedSource .where(and(...conditions)) .orderBy( + ...(filters?.oneChildPerParent ? [asc(issues.parentId)] : []), ...issueListOrderBy(companyId, { hasSearch, priorityOrder, @@ -8252,7 +8298,7 @@ export function issueService(db: Db) { ), })); const withLabels = await withIssueLabels(db, rows); - const runMap = await activeRunMapForIssues(db, withLabels); + const runMap = filters?.listProjection === "index" ? new Map() : await activeRunMapForIssues(db, withLabels); const withRuns = withActiveRuns(withLabels, runMap); if (withRuns.length === 0) { return withRuns; @@ -8298,8 +8344,8 @@ export function issueService(db: Db) { reviewAttentionByIssueId, blockedInboxAttentionByIssueId, ] = await Promise.all([ - listIssueBlockerAttentionMap(db, companyId, withRuns), - listIssueReviewAttentionMap(db, companyId, withRuns), + filters?.listProjection === "index" ? Promise.resolve(new Map()) : listIssueBlockerAttentionMap(db, companyId, withRuns), + filters?.listProjection === "index" ? Promise.resolve(new Map()) : listIssueReviewAttentionMap(db, companyId, withRuns), includeBlockedInboxAttention ? listIssueBlockedInboxAttentionMap(db, companyId, withRuns) : Promise.resolve(new Map()), diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index a53bb36d3e3..4f0249043c2 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -1,10 +1,11 @@ -import { and, desc, eq, inArray, not } from "drizzle-orm"; +import { and, desc, eq, inArray, not, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, heartbeatRuns } from "@paperclipai/db"; -import { isHeartbeatRunVisibleInMine, type SidebarBadges } from "@paperclipai/shared"; +import { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent, type SidebarBadges } from "@paperclipai/shared"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { getHeartbeatRunReadDecisions } from "./heartbeat-run-privacy.js"; const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"]; -const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"]; function normalizeTimestamp(value: Date | string | null | undefined): number { if (!value) return 0; @@ -23,6 +24,7 @@ function isDismissed( } export function sidebarBadgeService(db: Db) { + const authorization = authorizationService(db); return { get: async ( companyId: string, @@ -31,6 +33,8 @@ export function sidebarBadgeService(db: Db) { dismissals?: ReadonlyMap; joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>; unreadTouchedIssues?: number; + approvalReadCondition?: SQL; + actor?: AuthorizationActor; }, ): Promise => { const actionableApprovals = await db @@ -40,16 +44,21 @@ export function sidebarBadgeService(db: Db) { and( eq(approvals.companyId, companyId), inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES), + extra?.approvalReadCondition, ), ) .then((rows) => rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length ); - const latestRunByAgent = await db - .selectDistinctOn([heartbeatRuns.agentId], { + const recentRuns = await db + .select({ id: heartbeatRuns.id, - runStatus: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + companyId: heartbeatRuns.companyId, + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, + status: heartbeatRuns.status, responsibleUserId: heartbeatRuns.responsibleUserId, createdAt: heartbeatRuns.createdAt, }) @@ -62,13 +71,16 @@ export function sidebarBadgeService(db: Db) { not(eq(agents.status, "terminated")), ), ) - .orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt)); + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(200); - const failedRuns = latestRunByAgent.filter((row) => - FAILED_HEARTBEAT_STATUSES.includes(row.runStatus) - && (extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId)) + const failures = latestFailedRunsByAgent(recentRuns).filter((row) => + (extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId)) && !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt), - ).length; + ); + const failedRuns = extra?.actor + ? (await getHeartbeatRunReadDecisions(db, authorization, extra.actor, failures)).filter(Boolean).length + : failures.length; const joinRequests = (extra?.joinRequests ?? []).filter((row) => !isDismissed( diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b556a3cbac1..41684f9a9e3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -53,14 +53,16 @@ const AssistantConnectionsPage = lazy(() => import("./pages/McpConnect").then((m const ProductionLayout = lazy(() => import("./components/Layout.production").then((module) => ({ default: module.Layout }))); const TaskChatLab = lazy(() => import("./pages/TaskChatLab").then((module) => ({ default: module.TaskChatLab }))); -const Issues = lazy(() => import("./pages/Issues").then((module) => ({ default: module.Issues }))); +const loadIssues = () => import("./pages/Issues").then((module) => ({ default: module.Issues })); +const Issues = lazy(loadIssues); let issueDetailLoad: Promise<{ default: typeof import("./pages/IssueDetail").IssueDetail }> | undefined; const loadIssueDetail = () => (issueDetailLoad ??= import("./pages/IssueDetail").then((module) => ({ default: module.IssueDetail }))); const IssueDetail = lazy(loadIssueDetail); const AgentChat = lazy(() => import("./pages/AgentChat").then((module) => ({ default: module.AgentChat }))); const IssueChatLongThreadPerf = lazy(() => import("./pages/IssueChatLongThreadPerf").then((module) => ({ default: module.IssueChatLongThreadPerf }))); -const Inbox = lazy(() => import("./pages/Inbox").then((module) => ({ default: module.Inbox }))); +const loadInbox = () => import("./pages/Inbox").then((module) => ({ default: module.Inbox })); +const Inbox = lazy(loadInbox); const BootstrapSetupUxLab = lazy(() => import("./pages/BootstrapSetupUxLab").then((module) => ({ default: module.BootstrapSetupUxLab }))); const ResponsibleUserDenialUxLab = lazy(() => import("./pages/ResponsibleUserDenialUxLab").then((module) => ({ default: module.ResponsibleUserDenialUxLab }))); const CrossIssueCollaborationUxLab = lazy(() => import("./pages/CrossIssueCollaborationUxLab").then((module) => ({ default: module.CrossIssueCollaborationUxLab }))); @@ -763,9 +765,10 @@ export function App() { const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled(); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); useEffect(() => { - if (/^\/[^/]+\/(issues|tasks|inbox)(?:\/|$)/.test(pathname)) { - void loadIssueDetail().catch(() => undefined); - } + // Download the requested collection while access/company checks run. Detail + // code is already prefetched by row intent; don't compete with the cold list. + if (/^\/[^/]+\/inbox(?:\/|$)/.test(pathname)) void loadInbox().catch(() => undefined); + else if (/^\/[^/]+\/(issues|tasks)\/?$/.test(pathname)) void loadIssues().catch(() => undefined); }, [pathname]); return ( diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 0d919584e63..8fac92cdb1a 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -112,6 +112,7 @@ export type RuntimeRequestResolution = | { action: "submit"; response: import("@paperclipai/adapter-utils").PaperclipQuestionResponse }; export interface HeartbeatRunListOptions { + latestFailuresOnly?: boolean; summary?: boolean; } @@ -132,6 +133,7 @@ export const heartbeatsApi = { if (agentId) searchParams.set("agentId", agentId); if (limit) searchParams.set("limit", String(limit)); if (options.summary) searchParams.set("summary", "true"); + if (options.latestFailuresOnly) searchParams.set("latestFailuresOnly", "true"); const qs = searchParams.toString(); return api.get( `/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index f6f49ebd397..1f19291f103 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -87,6 +87,10 @@ export type ResolveRecoveryActionResponse = { }; export type IssueListFilters = { + ids?: readonly string[]; + parentIds?: readonly string[]; + oneChildPerParent?: boolean; + projection?: "index" | "rows"; attention?: "blocked"; status?: string; projectId?: string; @@ -126,6 +130,8 @@ function issueListSearchParams(filters?: IssueListFilters) { if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.parentId) params.set("parentId", filters.parentId); + if (filters?.parentIds) params.set("parentIds", filters.parentIds.join(",")); + if (filters?.oneChildPerParent) params.set("oneChildPerParent", "true"); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); if (filters?.participantAgentId) @@ -211,7 +217,8 @@ export const issuesApi = { options?: RequestOptions, ) => { const params = issueListSearchParams(filters); - params.set("view", "compact"); + params.set("view", filters?.projection ?? "compact"); + if (filters?.ids) params.set("ids", filters.ids.join(",")); const path = `/companies/${companyId}/issues?${params.toString()}`; return options ? api.get(path, options) diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 9ec5b3dd64e..e2d3b27dcf7 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,7 @@ import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { useState, type CSSProperties, type ReactNode } from "react"; import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared"; import { Link } from "@/lib/router"; -import { Archive, Flag } from "lucide-react"; +import { Archive, Flag, CircleAlert } from "lucide-react"; import { createIssueDetailPath, rememberIssueDetailLocationState, @@ -42,6 +42,8 @@ export interface IssueRowProps { appearance?: IssueRowAppearance; /** Refreshed task rows share their anatomy at either density. */ density?: IssueRowDensity; + /** Reserve collection geometry while optional diagnostics arrive. */ + stableLayout?: boolean; /** Opt-in complete column contract for refreshed task lists. */ columnValues?: TaskRowColumnValues; /** Interactive disclosure or selection control before the canonical status glyph. */ @@ -135,6 +137,7 @@ export function IssueRow({ presentation = "legacy", appearance = "default", density = "comfortable", + stableLayout = false, columnValues, leadingControl, statusSlot, @@ -294,6 +297,8 @@ export function IssueRow({ onMouseLeave={refreshed ? () => { clearHoveredCell(); setTitleFocused(false); } : undefined} role={refreshed && columnValues ? "row" : undefined} data-slot="task-row" + data-issue-id={issue.id} + data-issue-row-details={issue.id} data-unread={isUnread ? "true" : undefined} data-appearance={refreshed ? "refreshed" : undefined} data-density={refreshed ? density : undefined} @@ -310,6 +315,7 @@ export function IssueRow({ selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50", checklistCurrentStep && "bg-primary/5", refreshed && "task-row-refreshed", + stableLayout && !refreshed && "task-row-stable", className, )} > @@ -377,7 +383,21 @@ export function IssueRow({ > {issue.title}{titleSuffix} } - {recoveryIndicator} + {stableLayout ? ( + + {recoveryIndicator} + {recoveryIndicator ? ( + + + + + + + {recoveryIndicator} + + ) : null} + + ) : recoveryIndicator} {mobileTitleMeta ? ( {mobileTitleMeta} @@ -404,7 +424,9 @@ export function IssueRow({ ) : ( <> - {externalObjectSummary ? ( + {stableLayout ? ( + + ) : externalObjectSummary ? ( ) : null} {metadata ? {refreshed ? {metadata} : metadata} : null} @@ -431,6 +453,7 @@ export function IssueRow({ return (
void; + message?: string; + retryLabel?: string; +}) { + if (!error) return null; + return createPortal( +
+

{message}

+ +
, document.body, + ); +} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index ab89d8563f5..edee260d51b 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -379,6 +379,33 @@ describe("IssuesList", () => { container.remove(); }); + it("waits for the initial tree but keeps loaded rows visible while new ancestors load", async () => { + const first = createIssue({ id: "child-one", parentId: "parent-one", title: "First nested task" }); + const second = createIssue({ id: "child-two", parentId: "parent-two", title: "Second nested task" }); + let resolveAncestors: (rows: Issue[]) => void = () => undefined; + mockIssuesApi.list.mockImplementation((_company, filters) => filters.ids && filters.projection === "index" + ? new Promise((resolve) => { resolveAncestors = resolve; }) : Promise.resolve([])); + const view = (rows: Issue[]) => undefined} />; + const { root, queryClient } = renderWithQueryClient(view([first]), container); + await waitForAssertion(() => expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", + expect.objectContaining({ ids: ["parent-one"], projection: "index" }), expect.anything())); + expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); + const parent = createIssue({ id: "parent-one", title: "First parent" }); + await act(() => resolveAncestors([parent])); + await waitForAssertion(() => expect(container.textContent).toContain("First nested task")); + await act(() => root.render({view([first, second])})); + await waitForAssertion(() => expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", + expect.objectContaining({ ids: ["parent-one", "parent-two"], projection: "index" }), expect.anything())); + expect(container.textContent).toContain("First parent"); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + await act(() => resolveAncestors([parent, createIssue({ id: "parent-two", title: "Second parent" })])); + await waitForAssertion(() => expect(container.textContent).toContain("Second nested task")); + act(() => root.unmount()); + queryClient.clear(); + }); + it("uses the master list and legacy persistence when Streamlined UI is off", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, @@ -624,6 +651,29 @@ describe("IssuesList", () => { }); }); + it("shows a retryable external-status filter error instead of a false empty task list", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true }); + localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] })); + mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(new Error("Status lookup unavailable")); + const { root, queryClient } = renderWithQueryClient( undefined} + />, container); + await waitForAssertion(() => expect(container.textContent).toContain("Unable to filter tasks by external status")); + expect(container.textContent).not.toContain("No tasks match"); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { + "issue-failed": { total: 1, byStatusCategory: { failed: 1 }, objects: [] }, + } }); + await act(() => Array.from(container.querySelectorAll('button')).find((button) => button.textContent === "Retry external statuses")!.click()); + await waitForAssertion(() => expect(container.textContent).toContain("Matching task after retry")); + expect(container.textContent).not.toContain("Unable to filter tasks by external status"); + expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(2); + act(() => root.unmount()); + queryClient.clear(); + }); + it("renders server search results instead of filtering the full issue list locally", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); @@ -645,6 +695,7 @@ describe("IssuesList", () => { await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", + projection: undefined, projectId: undefined, limit: 200, }, { signal: expect.any(AbortSignal) }); @@ -679,6 +730,7 @@ describe("IssuesList", () => { await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", + projection: undefined, projectId: undefined, parentId: "parent-1", limit: 200, @@ -1577,7 +1629,7 @@ describe("IssuesList", () => { let holdNextRead = false; let releaseRead: (() => void) | undefined; mockIssuesApi.list.mockImplementation(async (_companyId, filters) => { - if (filters.parentId !== parent.id) return []; + if (!filters.parentIds?.includes(parent.id)) return []; const rows = children.slice(filters.offset ?? 0, (filters.offset ?? 0) + filters.limit); if (holdNextRead) { holdNextRead = false; diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 019c62a7b48..1af75084e93 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,3 +1,5 @@ +import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; +import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; @@ -919,13 +921,14 @@ function StreamlinedIssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, - "compact", + enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : "compact", ISSUE_SEARCH_RESULT_LIMIT, enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions", enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined, ], queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { + projection: enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : undefined, q: normalizedIssueSearch, projectId, limit: ISSUE_SEARCH_RESULT_LIMIT, @@ -1198,6 +1201,8 @@ function StreamlinedIssuesList({ summaries: externalObjectSummaryByIssueId, isLoading: externalObjectSummariesLoading, isReady: externalObjectSummariesReady, + error: externalObjectSummariesError, + refetch: retryExternalObjectSummaries, } = useIssueExternalObjectSummaries( selectedCompanyId, issueIdsForExternalObjectSummaries, @@ -1210,6 +1215,7 @@ function StreamlinedIssuesList({ const externalObjectFilterLoading = hasExternalObjectStatusFilters && externalObjectSummariesLoading && !externalObjectSummariesReady; + const externalObjectFilterError = hasExternalObjectStatusFilters ? externalObjectSummariesError : null; const filtered = useMemo(() => { const filteredByControls = applyIssueFilters( @@ -1307,6 +1313,9 @@ function StreamlinedIssuesList({ hierarchyEnabled, hierarchyRowBudget, ); + // Resolve the initial tree before reveal: inserting ancestors/children later would move rows. + // Hierarchy queries retain existing context during subsequent reads. + const collectionLoading = Boolean(isLoading || externalObjectFilterLoading || (hierarchyEnabled && hierarchy.isLoading)); const baseGroupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; @@ -1449,6 +1458,13 @@ function StreamlinedIssuesList({ viewState.nestingEnabled, ]); + const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( + selectedCompanyId, rootRef, + flatNavEntries.filter((entry) => entry.type === "issue").map((entry) => entry.type === "issue" ? entry.issue.id : "").join(",") + ":" + renderedIssueRowLimit, + enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" && !collectionLoading, + [...issues, ...hierarchy.issues], + ); + const listNavStateRef = useRef({ flatNavEntries, selectedNavKey, @@ -1777,6 +1793,7 @@ function StreamlinedIssuesList({ return (
+ void retryRowDetails()} /> {progressSummary ? ( - {(isLoading || externalObjectFilterLoading) && } + {collectionLoading && !externalObjectFilterError && } {error &&

{error.message}

} + {externalObjectFilterError && ( +
+

Unable to filter tasks by external status: {externalObjectFilterError.message}

+ +
+ )} {!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (

Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further. @@ -2055,7 +2079,7 @@ function StreamlinedIssuesList({

)} {hierarchy.error ?

Failed to load task hierarchy: {hierarchy.error.message}

: null} - {!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && ( + {!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && ( )} - {viewState.viewMode === "board" ? ( + {!collectionLoading && !externalObjectFilterError && (viewState.viewMode === "board" ? ( () }; const renderIssueRow = (issue: Issue, depth: number) => { + issue = enrichIssueRow(issue); if (remainingRowsToRender <= 0) return null; remainingRowsToRender -= 1; @@ -2236,6 +2261,7 @@ function StreamlinedIssuesList({ setNavSelectionFromPointer(`issue:${issue.id}`)} @@ -2251,7 +2277,9 @@ function StreamlinedIssuesList({ <> {hasChildren && !isExpanded ? ( - ({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""}) + {enableAgentCreatedVisibilityFilter + ? "(sub-tasks)" + : `(${totalDescendants} sub-task${totalDescendants !== 1 ? "s" : ""})`} ) : null} {issueBadge ? ( @@ -2564,7 +2592,7 @@ function StreamlinedIssuesList({
)}
- )} + ))} ); } diff --git a/ui/src/components/PageSkeleton.tsx b/ui/src/components/PageSkeleton.tsx index ef23654c31b..fabc126a27b 100644 --- a/ui/src/components/PageSkeleton.tsx +++ b/ui/src/components/PageSkeleton.tsx @@ -1,6 +1,7 @@ import { Skeleton } from "@/components/ui/skeleton"; interface PageSkeletonProps { + collectionHeading?: "date" | "group" | null; variant?: | "list" | "issues-list" @@ -12,7 +13,7 @@ interface PageSkeletonProps { | "org-chart"; } -export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { +export function PageSkeleton({ variant = "list", collectionHeading }: PageSkeletonProps) { if (variant === "dashboard") { return (
@@ -72,30 +73,6 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { ); } - if (variant === "inbox") { - return ( -
-
- - -
- -
- {Array.from({ length: 3 }).map((_, section) => ( -
- -
- {Array.from({ length: 3 }).map((_, row) => ( - - ))} -
-
- ))} -
-
- ); - } - if (variant === "org-chart") { return (
@@ -135,27 +112,25 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { ); } - if (variant === "issues-list") { + if (variant === "issues-list" || variant === "inbox") { return ( -
-
- -
- - - - +
+ {collectionHeading ? ( + -
- -
- -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} + ) : null} + {Array.from({ length: 8 }, (_, index) => ( + -
+ ))}
); } diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 7b9e6dffb3d..f1b33469b22 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -1,23 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { accessApi } from "../api/access"; -import { ApiError } from "../api/client"; +import { sidebarBadgesApi } from "../api/sidebarBadges"; import { inboxDismissalsApi } from "../api/inboxDismissals"; -import { approvalsApi } from "../api/approvals"; -import { authApi } from "../api/auth"; -import { dashboardApi } from "../api/dashboard"; -import { heartbeatsApi } from "../api/heartbeats"; -import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { - filterLocalInboxArchivedIssues, useLocalInboxArchiveIssueIds, } from "../lib/inboxArchiveCache"; import { usePublishSharedQueryData, useSharedPollingQuery } from "./useSharedPolling"; import { buildInboxDismissedAtByKey, - computeInboxBadgeData, - getRecentTouchedIssues, loadDismissedInboxAlerts, saveDismissedInboxAlerts, loadReadInboxItems, @@ -25,11 +16,6 @@ import { READ_ITEMS_KEY, } from "../lib/inbox"; -const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; -const INBOX_BADGE_ISSUE_LIMIT = 500; -const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200; -const INBOX_BADGE_HOT_PATH_STALE_MS = 30_000; - export function useDismissedInboxAlerts() { const [dismissed, setDismissed] = useState>(loadDismissedInboxAlerts); @@ -60,7 +46,7 @@ export function useInboxDismissals(companyId: string | null | undefined) { ? queryKeys.inboxDismissals(companyId) : ["inbox-dismissals", "__disabled__"] as const; - const { data: dismissals = [] } = useQuery({ + const { data: dismissals = [], isLoading, error } = useQuery({ queryKey, queryFn: () => inboxDismissalsApi.list(companyId!), enabled: !!companyId, @@ -136,6 +122,8 @@ export function useInboxDismissals(companyId: string | null | undefined) { return { dismissals, + isLoading, + error, dismissedAtByKey, dismiss, snooze, @@ -179,97 +167,32 @@ export function useReadInboxItems() { export function useInboxBadge(companyId: string | null | undefined) { const locallyArchivedIssueIds = useLocalInboxArchiveIssueIds(companyId); - const { dismissed: dismissedAlerts } = useDismissedInboxAlerts(); - const { dismissedAtByKey } = useInboxDismissals(companyId); - const { data: session } = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - }); - - const { data: approvals = [] } = useQuery({ - queryKey: queryKeys.approvals.list(companyId!), - queryFn: () => approvalsApi.list(companyId!), - enabled: !!companyId, - }); - - const { data: joinRequests = [] } = useQuery({ - queryKey: queryKeys.access.joinRequests(companyId!), - queryFn: async () => { - try { - return await accessApi.listJoinRequests(companyId!, "pending_approval"); - } catch (err) { - if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { - return []; - } - throw err; - } - }, - enabled: !!companyId, - retry: false, - }); - - const dashboardQueryKey = queryKeys.dashboard(companyId!); - const sharedDashboard = useSharedPollingQuery({ + const badgeQueryKey = queryKeys.sidebarBadges(companyId ?? "__disabled__"); + const sharedBadges = useSharedPollingQuery({ companyId, - resourceKey: "dashboard", - queryKey: dashboardQueryKey, + resourceKey: "inbox-badge", + queryKey: badgeQueryKey, enabled: !!companyId, }); - const { data: dashboard, dataUpdatedAt: dashboardUpdatedAt } = useQuery({ - queryKey: dashboardQueryKey, - queryFn: () => dashboardApi.summary(companyId!), - enabled: !!companyId, - }); - usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt); - - const mineIssuesQueryKey = queryKeys.issues.listMineByMe(companyId!); - const sharedMineIssues = useSharedPollingQuery({ - companyId, - resourceKey: "inbox-badge:mine-issues", - queryKey: mineIssuesQueryKey, - enabled: !!companyId, - }); - const { data: mineIssuesRaw = [], dataUpdatedAt: mineIssuesUpdatedAt } = useQuery({ - queryKey: mineIssuesQueryKey, - queryFn: () => - issuesApi.list(companyId!, { - touchedByUserId: "me", - inboxArchivedByUserId: "me", - status: INBOX_ISSUE_STATUSES, - limit: INBOX_BADGE_ISSUE_LIMIT, - }), + const { data: badges, dataUpdatedAt } = useQuery({ + queryKey: badgeQueryKey, + queryFn: () => sidebarBadgesApi.get(companyId!), enabled: !!companyId, refetchOnWindowFocus: false, - staleTime: INBOX_BADGE_HOT_PATH_STALE_MS, + staleTime: 30_000, }); - usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt); + usePublishSharedQueryData(sharedBadges, badges, dataUpdatedAt); - const mineIssues = useMemo( - () => getRecentTouchedIssues(filterLocalInboxArchivedIssues(companyId, mineIssuesRaw)), - [companyId, locallyArchivedIssueIds, mineIssuesRaw], - ); - const currentUserId = session?.user.id ?? session?.session.userId ?? null; - - const { data: heartbeatRuns = [] } = useQuery({ - queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT], - queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT, { summary: true }), - enabled: !!companyId, - refetchOnWindowFocus: false, - staleTime: INBOX_BADGE_HOT_PATH_STALE_MS, - }); - - return useMemo( - () => - computeInboxBadgeData({ - approvals, - joinRequests, - dashboard, - heartbeatRuns, - mineIssues, - dismissedAlerts, - dismissedAtByKey, - currentUserId, - }), - [approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey, currentUserId], - ); + return useMemo(() => { + const unreadIssueIds = badges?.unreadIssueIds ?? []; + const archivedUnread = unreadIssueIds.filter((id) => locallyArchivedIssueIds.has(id)).length; + return { + inbox: Math.max(0, (badges?.inbox ?? 0) - archivedUnread), + approvals: badges?.approvals ?? 0, + failedRuns: badges?.failedRuns ?? 0, + joinRequests: badges?.joinRequests ?? 0, + mineIssues: unreadIssueIds.length - archivedUnread, + alerts: 0, + }; + }, [badges, locallyArchivedIssueIds]); } diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index 8f8052afb0d..12ac44a4f01 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -207,6 +207,8 @@ export function useIssueExternalObjectSummaries( summaries: Map; isLoading: boolean; isReady: boolean; + error: Error | null; + refetch: () => void; } { const externalObjectsFeature = useExternalObjectsFeature(); const normalizedIssueIds = useMemo( @@ -228,6 +230,8 @@ export function useIssueExternalObjectSummaries( summaries, isLoading: enabled && query.isLoading, isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess), + error: query.error, + refetch: () => { void query.refetch(); }, }; } diff --git a/ui/src/hooks/useIssueHierarchyContext.ts b/ui/src/hooks/useIssueHierarchyContext.ts index 39808380c5a..053462eb07a 100644 --- a/ui/src/hooks/useIssueHierarchyContext.ts +++ b/ui/src/hooks/useIssueHierarchyContext.ts @@ -1,50 +1,54 @@ import { loadIssueHierarchyChildren } from "../lib/issue-hierarchy-context"; -import { useQueries, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; -import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; export function useIssueHierarchyContext(companyId: string | null, matches: Issue[], collapsedParents: string[], enabled: boolean, rowBudget: number) { - const parents = [...new Set(matches.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)))]; - const ancestorQueries = useQueries({ queries: parents.map((parentId) => ({ - queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-ancestors", parentId], - enabled: enabled && Boolean(companyId), + const parents = [...new Set(matches.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)))].sort(); + const ancestors = useQuery({ + queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-index-ancestors", parents], + enabled: enabled && Boolean(companyId) && parents.length > 0, staleTime: 30_000, - queryFn: async ({ signal }: { signal: AbortSignal }) => { - const ancestors: Issue[] = []; + placeholderData: (previous) => previous?.filter((issue) => issue.companyId === companyId), + queryFn: async ({ signal }) => { + const rows: Issue[] = []; const visited = new Set(); - let id: string | null = parentId; - while (id && !visited.has(id)) { - visited.add(id); - try { - const parent: Issue = await issuesApi.get(id, { signal }); - if (parent.companyId !== companyId) break; - ancestors.push(parent); - id = parent.parentId; - } catch (error) { - if (error instanceof ApiError && (error.status === 403 || error.status === 404)) break; - throw error; + let frontier = parents; + while (frontier.length > 0) { + const next: string[] = []; + for (let offset = 0; offset < frontier.length; offset += 100) { + const ids = frontier.slice(offset, offset + 100); + ids.forEach((id) => visited.add(id)); + const batch = await issuesApi.listCompact(companyId!, { ids, projection: "index", limit: 100, includeRoutineExecutions: true }, { signal }); + for (const issue of batch) { + if (issue.companyId !== companyId) continue; + rows.push(issue as Issue); + if (issue.parentId && !visited.has(issue.parentId)) next.push(issue.parentId); + } } + frontier = [...new Set(next)].filter((id) => !visited.has(id)); } - return ancestors; + return rows; }, - })) }); + }); const children = useQuery>>({ - queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-children", matches.map((issue) => issue.id), collapsedParents, rowBudget], + queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-index-children", matches.map((issue) => issue.id), collapsedParents, rowBudget], enabled: enabled && Boolean(companyId) && matches.length > 0, staleTime: 30_000, placeholderData: (previous) => previous ? { ...previous, issues: previous.issues.filter((issue) => issue.companyId === companyId), } : undefined, queryFn: ({ signal }) => loadIssueHierarchyChildren(companyId!, matches, collapsedParents, rowBudget, - (parentId, limit, offset) => issuesApi.list(companyId!, { parentId, limit, offset, includeRoutineExecutions: true }, { signal })), + async (parentIds, limit, offset, oneChildPerParent) => await issuesApi.listCompact(companyId!, { + parentIds, oneChildPerParent, projection: "index", limit, offset, includeRoutineExecutions: true, + }, { signal }) as Issue[]), }); - const queries = [...ancestorQueries, children]; return { - issues: [...ancestorQueries.flatMap((query) => query.data ?? []), ...(children.data?.issues ?? [])], + issues: [...(ancestors.data ?? []), ...(children.data?.issues ?? [])], hasMore: children.data?.hasMore ?? false, - isFetching: children.isFetching, - error: queries.find((query) => query.error)?.error, + isLoading: ancestors.isLoading || children.isLoading, + isFetching: ancestors.isFetching || children.isFetching, + error: ancestors.error ?? children.error, }; } diff --git a/ui/src/hooks/useIssueRowDetails.test.ts b/ui/src/hooks/useIssueRowDetails.test.ts new file mode 100644 index 00000000000..a83f8a9fa70 --- /dev/null +++ b/ui/src/hooks/useIssueRowDetails.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { mergeIssueRowDetails } from "./useIssueRowDetails"; + +const index = { + id: "task-1", companyId: "company-1", title: "Current title", status: "todo", + updatedAt: new Date("2026-10-26T10:00:00Z"), isUnreadForMe: true, listProjection: "index", +} as Issue; + +describe("visible task row enrichment", () => { + it("keeps the index's ordering, read state, and title when diagnostics arrive", () => { + const details = { + ...index, title: "Old title", status: "done", isUnreadForMe: false, + activeRecoveryAction: { id: "recovery-1" }, + } as Issue; + const merged = mergeIssueRowDetails(index, details); + expect(merged).toMatchObject({ + title: "Current title", status: "todo", isUnreadForMe: true, listProjection: "index", + activeRecoveryAction: { id: "recovery-1" }, + }); + expect(merged.updatedAt).toBe(index.updatedAt); + }); + + it("rejects stale diagnostics and a different task or company", () => { + for (const overrides of [ + { updatedAt: new Date("2026-10-26T09:00:00Z") }, + { id: "task-2" }, + { companyId: "company-2" }, + ]) { + expect(mergeIssueRowDetails(index, { ...index, ...overrides })).toBe(index); + } + }); + + it("accepts newer diagnostics without replacing the index's order or content", () => { + const merged = mergeIssueRowDetails(index, { + ...index, updatedAt: new Date("2026-10-26T10:00:01Z"), title: "Changed during loading", + activeRecoveryAction: { id: "new-recovery" }, + } as Issue); + expect(merged.title).toBe(index.title); + expect(merged.updatedAt).toBe(index.updatedAt); + expect(merged.activeRecoveryAction?.id).toBe("new-recovery"); + }); +}); diff --git a/ui/src/hooks/useIssueRowDetails.ts b/ui/src/hooks/useIssueRowDetails.ts new file mode 100644 index 00000000000..64511016684 --- /dev/null +++ b/ui/src/hooks/useIssueRowDetails.ts @@ -0,0 +1,90 @@ +import { useEffect, useMemo, useState, type RefObject } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; + +/** Enrich visible rows without replacing the index that owns membership and order. */ +export function mergeIssueRowDetails(issue: Issue, details: Issue | undefined): Issue { + if (!details || details.id !== issue.id || details.companyId !== issue.companyId + || new Date(details.updatedAt).getTime() < new Date(issue.updatedAt).getTime()) return issue; + return { + ...issue, + blockerAttention: details.blockerAttention, + reviewAttention: details.reviewAttention, + activeRecoveryAction: details.activeRecoveryAction, + interruption: details.interruption, + successfulRunHandoff: details.successfulRunHandoff, + scheduledRetry: details.scheduledRetry, + }; +} + +export function useIssueRowDetails( + companyId: string | null | undefined, + root: RefObject, + rowIdentity: string, + enabled: boolean, + issues: readonly Issue[], +) { + const [visibleIds, setVisibleIds] = useState([]); + const [saved, setSaved] = useState<{ companyId: string; rows: Map } | null>(null); + useEffect(() => { + const element = root.current; + if (!enabled || !element) { + setVisibleIds([]); + return; + } + const nodes = [...element.querySelectorAll("[data-issue-row-details]")]; + const visible = new Set(); + const publish = () => { + const next = [...visible].sort().slice(0, 50); + setVisibleIds((previous) => previous.join(",") === next.join(",") ? previous : next); + }; + if (typeof IntersectionObserver === "undefined") { + for (const node of nodes.slice(0, 50)) visible.add(node.dataset.issueRowDetails!); + publish(); + return; + } + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.issueRowDetails!; + if (entry.isIntersecting) visible.add(id); + else visible.delete(id); + } + publish(); + }); + for (const node of nodes) observer.observe(node); + return () => observer.disconnect(); + }, [companyId, root, rowIdentity, enabled]); + + const versions = new Map(); + for (const issue of issues) { + versions.set(issue.id, Math.max(versions.get(issue.id) ?? 0, new Date(issue.updatedAt).getTime())); + } + const visibleVersions = visibleIds.map((id) => [id, versions.get(id)]); + const { data, error, refetch } = useQuery({ + queryKey: [...queryKeys.issues.list(companyId ?? "__disabled__"), "visible-row-details", visibleVersions], + queryFn: ({ signal }) => issuesApi.listCompact(companyId!, { + ids: visibleIds, projection: "rows", includeRoutineExecutions: true, limit: 50, + }, { signal }), + enabled: enabled && !!companyId && visibleIds.length > 0, + staleTime: 30_000, + }); + useEffect(() => { + if (!companyId || !data) return; + setSaved((previous) => { + const rows = new Map(previous?.companyId === companyId ? previous.rows : []); + for (const issue of data) { + const previousRow = rows.get(issue.id); + if (!previousRow || new Date(issue.updatedAt).getTime() >= new Date(previousRow.updatedAt).getTime()) { + rows.set(issue.id, issue as Issue); + } + } + return { companyId, rows }; + }); + }, [companyId, data]); + const enrichIssueRow = useMemo(() => (issue: Issue) => mergeIssueRowDetails( + issue, saved && saved.companyId === companyId ? saved.rows.get(issue.id) : undefined, + ), [companyId, saved]); + return { enrichIssueRow, error, retry: refetch }; +} diff --git a/ui/src/index.css b/ui/src/index.css index c831b655bf1..27e9dc1edc7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -3679,6 +3679,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { :root, .dark { --task-row-height-comfortable: calc(var(--spacing) * 11); --task-row-height-compact: calc(var(--spacing) * 8); + --task-row-recovery-width: calc(var(--spacing) * 32); + --task-row-external-width: calc(var(--spacing) * 10); --task-row-padding-inline: calc(var(--spacing) * 4); --task-row-gap: calc(var(--spacing) * 2); --task-row-indent-step: calc(var(--spacing) * 6); @@ -3716,6 +3718,40 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { } .task-row-group + .task-row-group { border-top: 0; } +.task-row-stable, .task-row-skeleton { + height: var(--task-row-height-comfortable); + min-height: var(--task-row-height-comfortable); +} +.task-row-stable { align-items: center; } +.task-row-stable [data-slot="task-row-leading"] { align-items: center; padding-top: 0; } +.task-row-stable [data-slot="task-row-title"] { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.task-row-stable [data-slot="task-row-recovery"] { + width: var(--task-row-control-size); + flex-shrink: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.task-row-stable [data-slot="task-row-external"] { + width: var(--task-row-external-width); + flex-shrink: 0; +} +.task-row-stable [data-slot="task-row-metadata"] { + width: var(--task-row-time-width); + flex-shrink: 0; + overflow: hidden; + white-space: nowrap; +} +.task-row-stable [data-slot="task-row-content"] { align-self: center; } +@media (min-width: 640px) { + .task-row-stable [data-slot="task-row-recovery"] { width: var(--task-row-recovery-width); } +} +.task-row-skeleton { display: flex; align-items: center; gap: var(--task-row-gap); padding-inline: var(--task-row-padding-inline); } .task-row-refreshed { --task-row-height: var(--task-row-height-comfortable); --task-row-guide-end-gap: calc((var(--task-row-height) - var(--task-row-control-size)) / 2); diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 836da02bc75..367eeee9acb 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, - DashboardSummary, ExecutionWorkspace, ExternalObjectSummary, HeartbeatRun, @@ -17,7 +16,6 @@ import { buildInboxIssueGroupCreateDefaults, buildInboxKeyboardNavEntries, buildInboxDismissedAtByKey, - computeInboxBadgeData, getArchivedInboxSearchIssues, getAvailableInboxIssueColumns, getInboxWorkItemKey, @@ -283,130 +281,11 @@ function makeExecutionWorkspace(overrides: Partial = {}): Ex }; } -const dashboard: DashboardSummary = { - companyId: "company-1", - agents: { - active: 1, - running: 0, - paused: 0, - error: 1, - }, - tasks: { - open: 1, - inProgress: 0, - blocked: 0, - done: 0, - }, - costs: { - monthSpendCents: 900, - monthBudgetCents: 1000, - monthUtilizationPercent: 90, - }, - pendingApprovals: 1, - budgets: { - activeIncidents: 0, - pendingApprovals: 0, - pausedAgents: 0, - pausedProjects: 0, - }, - runActivity: [], -}; - describe("inbox helpers", () => { beforeEach(() => { storage.clear(); }); - it.each([ - { currentUserId: "user-1", expected: 1 }, - { currentUserId: "user-2", expected: 1 }, - { currentUserId: "local-board", expected: 1 }, - { currentUserId: null, expected: 0 }, - ])("counts only personal failed runs for $currentUserId", ({ currentUserId, expected }) => { - const result = computeInboxBadgeData({ - approvals: [], joinRequests: [], dashboard, mineIssues: [], - dismissedAlerts: new Set(), dismissedAtByKey: new Map(), currentUserId, - heartbeatRuns: [ - { ...makeRun("own", "failed", "2026-03-11T01:00:00Z"), responsibleUserId: "user-1" }, - { ...makeRun("other", "timed_out", "2026-03-11T01:00:00Z", "agent-2"), responsibleUserId: "user-2" }, - { ...makeRun("unowned", "failed", "2026-03-11T01:00:00Z", "agent-3"), responsibleUserId: null }, - { ...makeRun("old-own", "failed", "2026-03-11T01:00:00Z", "shared-agent"), responsibleUserId: "user-1" }, - { ...makeRun("new-other", "succeeded", "2026-03-11T02:00:00Z", "shared-agent"), responsibleUserId: "user-2" }, - ], - }); - expect(result.failedRuns).toBe(expected); - expect(result.inbox).toBe(expected); - expect(result.alerts).toBe(1); // The budget alert; run failures already describe agent errors in All. - }); - - it("counts the same inbox sources the badge uses", () => { - const result = computeInboxBadgeData({ - approvals: [ - { ...makeApproval("pending"), requestedByUserId: "user-1" }, - { ...makeApproval("approved"), requestedByUserId: "user-2" }, - ], - joinRequests: [makeJoinRequest("join-1")], - dashboard, - heartbeatRuns: [ - makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"), - makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"), - makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), - ], - mineIssues: [makeIssue("1", true)], - dismissedAlerts: new Set(), - dismissedAtByKey: new Map(), - currentUserId: "user-1", - }); - - expect(result).toEqual({ - inbox: 5, - approvals: 1, - failedRuns: 2, - joinRequests: 1, - mineIssues: 1, - alerts: 1, - }); - }); - - it("drops dismissed runs and alerts from the computed badge", () => { - const result = computeInboxBadgeData({ - approvals: [], - joinRequests: [], - dashboard, - heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], - mineIssues: [], - dismissedAlerts: new Set(["alert:budget", "alert:agent-errors"]), - dismissedAtByKey: new Map([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]), - currentUserId: "user-1", - }); - - expect(result).toEqual({ - inbox: 0, - approvals: 0, - failedRuns: 0, - joinRequests: 0, - mineIssues: 0, - alerts: 0, - }); - }); - - it("excludes read mine issues from the inbox badge count", () => { - const result = computeInboxBadgeData({ - approvals: [], - joinRequests: [], - dashboard, - heartbeatRuns: [], - mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)], - dismissedAlerts: new Set(), - dismissedAtByKey: new Map(), - currentUserId: "user-1", - }); - - expect(result.mineIssues).toBe(1); - expect(result.inbox).toBe(1); - expect(result.alerts).toBe(2); - }); - it("resurfaces non-issue items when they change after dismissal", () => { const dismissedAtByKey = buildInboxDismissedAtByKey([ { @@ -480,7 +359,7 @@ describe("inbox helpers", () => { ]); }); - it("surfaces agent-requested actionable approvals in mine and the badge", () => { + it("surfaces agent-requested actionable approvals in mine", () => { const approvals = [ { ...makeApprovalWithTimestamps("approval-agent-requested", "pending", "2026-03-11T02:00:00.000Z"), @@ -496,34 +375,6 @@ describe("inbox helpers", () => { "approval-agent-requested", ]); - const result = computeInboxBadgeData({ - approvals, - joinRequests: [], - dashboard, - heartbeatRuns: [], - mineIssues: [], - dismissedAlerts: new Set(), - dismissedAtByKey: new Map(), - currentUserId: "user-1", - }); - - expect(result.approvals).toBe(1); - }); - - it("does not count company-wide alerts in the personal inbox badge", () => { - const result = computeInboxBadgeData({ - approvals: [], - joinRequests: [], - dashboard, - heartbeatRuns: [], - mineIssues: [], - dismissedAlerts: new Set(), - dismissedAtByKey: new Map(), - currentUserId: "user-1", - }); - - expect(result.alerts).toBe(2); - expect(result.inbox).toBe(0); }); it("mixes approvals into the inbox feed by most recent activity", () => { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 89c03541a15..d941eeb682c 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -1,7 +1,5 @@ -import { isHeartbeatRunVisibleInMine } from "@paperclipai/shared"; import type { Approval, - DashboardSummary, HeartbeatRun, InboxDismissal, Issue, @@ -17,7 +15,6 @@ import { import { formatAssigneeUserLabel } from "./assignees"; export const RECENT_ISSUES_LIMIT = 100; -export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const READ_ITEMS_KEY = "paperclip:inbox:read-items"; @@ -78,15 +75,6 @@ export type InboxWorkItem = joinRequest: JoinRequest; }; -export interface InboxBadgeData { - inbox: number; - approvals: number; - failedRuns: number; - joinRequests: number; - mineIssues: number; - alerts: number; -} - export interface InboxWorkItemGroup { key: string; label: string | null; @@ -428,19 +416,22 @@ export function shouldResetInboxWorkspaceGrouping( export function matchesInboxIssueSearch( - issue: Pick, + issue: Pick & { id?: string }, query: string, { isolatedWorkspacesEnabled = false, + matchedIssueIds, executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, }: InboxWorkspaceGroupingOptions & { isolatedWorkspacesEnabled?: boolean; + matchedIssueIds?: ReadonlySet; } = {}, ): boolean { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return true; + if (issue.id && matchedIssueIds?.has(issue.id)) return true; if (issue.title.toLowerCase().includes(normalizedQuery)) return true; if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true; if (issue.description?.toLowerCase().includes(normalizedQuery)) return true; @@ -458,6 +449,7 @@ export function getArchivedInboxSearchIssues({ visibleIssues, searchableIssues, query, + matchedIssueIds, isolatedWorkspacesEnabled = false, executionWorkspaceById, projectWorkspaceById, @@ -466,6 +458,7 @@ export function getArchivedInboxSearchIssues({ visibleIssues: Issue[]; searchableIssues: Issue[]; query: string; + matchedIssueIds?: ReadonlySet; isolatedWorkspacesEnabled?: boolean; executionWorkspaceById?: ReadonlyMap; projectWorkspaceById?: ReadonlyMap; @@ -479,6 +472,7 @@ export function getArchivedInboxSearchIssues({ .filter((issue) => !visibleIssueIds.has(issue.id)) .filter((issue) => matchesInboxIssueSearch(issue, normalizedQuery, { + matchedIssueIds, isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, @@ -725,20 +719,7 @@ export function getInboxKeyboardSelectionIndex( : Math.max(previousIndex - 1, 0); } -export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { - const sorted = [...runs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - const latestByAgent = new Map(); - - for (const run of sorted) { - if (!latestByAgent.has(run.agentId)) { - latestByAgent.set(run.agentId, run); - } - } - - return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status)); -} +export { latestFailedRunsByAgent as getLatestFailedRunsByAgent } from "@paperclipai/shared"; export function normalizeTimestamp(value: string | Date | null | undefined): number { if (!value) return 0; @@ -1257,60 +1238,3 @@ export function shouldShowInboxSection({ if (tab === "unread") return showOnUnread; return showOnAll; } - -export function computeInboxBadgeData({ - approvals, - joinRequests, - dashboard, - heartbeatRuns, - mineIssues, - dismissedAlerts, - dismissedAtByKey, - currentUserId, -}: { - approvals: Approval[]; - joinRequests: JoinRequest[]; - dashboard: DashboardSummary | undefined; - heartbeatRuns: HeartbeatRun[]; - mineIssues: Issue[]; - dismissedAlerts: Set; - dismissedAtByKey: ReadonlyMap; - currentUserId?: string | null; -}): InboxBadgeData { - const actionableApprovals = approvals.filter( - (approval) => - isApprovalVisibleInMine(approval, currentUserId) && - ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && - !isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt), - ).length; - const visibleFailedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( - (run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt), - ); - const failedRuns = visibleFailedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)).length; - const visibleJoinRequests = joinRequests.filter( - (jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt), - ).length; - const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length; - const agentErrorCount = dashboard?.agents.error ?? 0; - const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; - const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; - const showAggregateAgentError = - agentErrorCount > 0 && - visibleFailedRuns.length === 0 && - !dismissedAlerts.has("alert:agent-errors"); - const showBudgetAlert = - monthBudgetCents > 0 && - monthUtilizationPercent >= 80 && - !dismissedAlerts.has("alert:budget"); - const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert); - - return { - // The inbox badge reflects personal/actionable work, not company-wide health alerts. - inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues, - approvals: actionableApprovals, - failedRuns, - joinRequests: visibleJoinRequests, - mineIssues: visibleMineIssues, - alerts, - }; -} diff --git a/ui/src/lib/issue-hierarchy-context.test.ts b/ui/src/lib/issue-hierarchy-context.test.ts index 1d63fa27689..fd95ef222ff 100644 --- a/ui/src/lib/issue-hierarchy-context.test.ts +++ b/ui/src/lib/issue-hierarchy-context.test.ts @@ -23,28 +23,47 @@ describe("bounded hierarchy reads", () => { it("caps rows and requests, and shares overlapping matching subtrees", async () => { const root = task("root"); const child = task("child", "root"); - const calls: string[] = []; - const read = async (id: string, limit: number, offset: number) => { - calls.push(id); - const rows = id === "root" ? [child] : id === "child" - ? Array.from({ length: 500 }, (_, index) => task(`nested-${index}`, "child")) : []; + const calls: string[][] = []; + const read = async (ids: string[], limit: number, offset: number) => { + calls.push(ids); + const rows = [child, ...Array.from({ length: 500 }, (_, index) => task(`nested-${index}`, "child"))].filter((issue) => ids.includes(issue.parentId!)); return rows.slice(offset, offset + limit); }; const first = await loadIssueHierarchyChildren("company", [root, child], [], 5, read); expect(first.issues).toHaveLength(5); expect(first.hasMore).toBe(true); - expect(calls).toEqual(["root", "child"]); + expect(calls).toEqual([["root", "child"]]); calls.length = 0; expect((await loadIssueHierarchyChildren("company", [root, child], [], 10, read)).issues).toHaveLength(10); - expect(calls).toEqual(["root", "child"]); + expect(calls).toEqual([["root", "child"]]); }); - it("probes collapsed parents without fetching their tree and rejects foreign-company rows", async () => { - const calls: Array<[string, number]> = []; + it("reads collapsed parents without traversing their tree and rejects foreign-company rows", async () => { + const calls: Array<[string[], number]> = []; const rows = await loadIssueHierarchyChildren("company", [task("root")], ["root"], 5, async (id, limit) => { calls.push([id, limit]); return [task("child", "root")]; }); expect(rows.issues.map((issue) => issue.id)).toEqual(["child"]); - expect(calls).toEqual([["root", 1]]); + expect(calls).toEqual([[["root"], 1]]); expect(await loadIssueHierarchyChildren("company", [task("root")], [], 1, async () => [{ ...task("foreign", "root"), companyId: "other" }])).toEqual({ issues: [], hasMore: true }); }); + it("keeps a large collapsed tree from spending an expanded sibling's budget", async () => { + const hidden = Array.from({ length: 200 }, (_, index) => task(`hidden-${index}`, "collapsed")); + const visible = task("visible", "expanded"); + const result = await loadIssueHierarchyChildren("company", [task("collapsed"), task("expanded")], ["collapsed"], 2, + async (ids, limit, offset) => [...hidden, visible].filter((row) => ids.includes(row.parentId!)).slice(offset, offset + limit)); + expect(result.issues.map((row) => row.id)).toEqual(["hidden-0", "visible"]); + expect(result.hasMore).toBe(false); + }); + it("checks many collapsed parents in one bounded request", async () => { + const parents = Array.from({ length: 30 }, (_, index) => task(`parent-${index}`)); + const calls: unknown[] = []; + const result = await loadIssueHierarchyChildren("company", parents, parents.map((row) => row.id), 100, + async (ids, limit, offset, oneChildPerParent) => { + calls.push({ ids, limit, offset, oneChildPerParent }); + return ids.map((id) => task(`child-${id}`, id)); + }); + expect(calls).toEqual([{ ids: parents.map((row) => row.id), limit: 30, offset: 0, oneChildPerParent: true }]); + expect(result.issues).toHaveLength(30); + expect(result.hasMore).toBe(false); + }); }); diff --git a/ui/src/lib/issue-hierarchy-context.ts b/ui/src/lib/issue-hierarchy-context.ts index 2dc328e1a62..bb07018f306 100644 --- a/ui/src/lib/issue-hierarchy-context.ts +++ b/ui/src/lib/issue-hierarchy-context.ts @@ -31,34 +31,65 @@ export function includeIssueHierarchyContext(matches: Issue[], context: Issue[]) return [...result.values()]; } -/** Fetch each parent once and stop at the collection's current rendering budget. */ +/** Batch each breadth-first frontier; leaf-heavy lists need one read, not one per task. */ export async function loadIssueHierarchyChildren( companyId: string, matches: Issue[], collapsedParents: string[], rowBudget: number, - readChildren: (parentId: string, limit: number, offset: number) => Promise, + readChildren: (parentIds: string[], limit: number, offset: number, oneChildPerParent?: boolean) => Promise, ): Promise<{ issues: Issue[]; hasMore: boolean }> { const context: Issue[] = []; - const queue = matches.map((issue) => ({ issue, offset: 0 })); const visited = new Set(); const fetched = new Set(); + const collapsed = new Set(collapsedParents); let requests = 0; - while (queue.length > 0 && context.length < rowBudget && requests < rowBudget) { - const { issue, offset } = queue.shift()!; - if (offset === 0 && visited.has(issue.id)) continue; - visited.add(issue.id); - const collapsed = collapsedParents.includes(issue.id); - const limit = collapsed ? 1 : Math.min(100, rowBudget - context.length); - const rows = await readChildren(issue.id, limit, offset); - requests += 1; - for (const child of rows) { - if (child.companyId !== companyId || fetched.has(child.id)) continue; - fetched.add(child.id); - context.push(child); - if (!collapsed) queue.push({ issue: child, offset: 0 }); + let loadedRows = 0; + let probes = 0; + let moreCollapsedMarkers = false; + let frontier = [...new Set(matches.map((issue) => issue.id))]; + while (frontier.length > 0 && loadedRows < rowBudget) { + const next: string[] = []; + const collapsedFrontier = frontier.filter((id) => collapsed.has(id) && !visited.has(id)); + const collapsedIds = collapsedFrontier.slice(0, Math.max(0, rowBudget - probes)); + moreCollapsedMarkers ||= collapsedIds.length < collapsedFrontier.length; + collapsedFrontier.forEach((id) => visited.add(id)); + // One child is enough to keep a collapsed parent's expand affordance. These + // markers must not spend the budget reserved for expanded, readable rows. + for (let start = 0; start < collapsedIds.length; start += 100) { + const batch = collapsedIds.slice(start, start + 100); + const markers = await readChildren(batch, batch.length, 0, true); + probes += batch.length; + for (const child of markers) { + if (child.companyId !== companyId || fetched.has(child.id)) continue; + fetched.add(child.id); + context.push(child); + } } - if (!collapsed && rows.length === limit) queue.unshift({ issue, offset: offset + rows.length }); + for (let start = 0; start < frontier.length; start += 100) { + const parentIds = frontier.slice(start, start + 100).filter((id) => !visited.has(id)); + if (parentIds.length === 0) continue; + parentIds.forEach((id) => visited.add(id)); + let offset = 0; + while (loadedRows < rowBudget) { + if (requests >= rowBudget) return { issues: context, hasMore: true }; + requests += 1; + const limit = Math.min(100, rowBudget - loadedRows); + const rows = await readChildren(parentIds, limit, offset); + for (const child of rows) { + if (child.companyId !== companyId || fetched.has(child.id)) continue; + fetched.add(child.id); + context.push(child); + loadedRows += 1; + if (!visited.has(child.id)) next.push(child.id); + } + if (rows.length < limit) break; + offset += rows.length; + if (loadedRows >= rowBudget) return { issues: context, hasMore: true }; + } + if (loadedRows >= rowBudget && (start + 100 < frontier.length || next.length > 0)) return { issues: context, hasMore: true }; + } + frontier = [...new Set(next)].filter((id) => !visited.has(id)); } - return { issues: context, hasMore: queue.length > 0 }; + return { issues: context, hasMore: moreCollapsedMarkers || frontier.length > 0 }; } diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 48c126af364..0d066cecc89 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -38,7 +38,8 @@ function isCompleteIssueSnapshot(value: unknown): value is Issue { if (typeof value !== "object" || value === null) return false; const issue = value as Partial; return ( - isNonEmptyString(issue.id) + issue.listProjection === undefined + && isNonEmptyString(issue.id) && isNonEmptyString(issue.companyId) && typeof issue.title === "string" && typeof issue.status === "string" diff --git a/ui/src/lib/prefetchIssueComments.test.ts b/ui/src/lib/prefetchIssueComments.test.ts index 3588b75f10f..eb948a0db47 100644 --- a/ui/src/lib/prefetchIssueComments.test.ts +++ b/ui/src/lib/prefetchIssueComments.test.ts @@ -88,6 +88,17 @@ describe("prefetchIssueComments", () => { expect(issuesApi.listComments).toHaveBeenCalledTimes(1); }); + it.each(["index", "rows"] as const)("fetches the full task when navigating from a %s projection", async (listProjection) => { + const issue = createIssue(); + vi.mocked(issuesApi.get).mockResolvedValue(issue); + vi.mocked(issuesApi.listComments).mockResolvedValue([]); + await prefetchIssueDetailForNavigation(queryClient, issue.identifier!, { + issue: { ...issue, listProjection, description: null }, + }); + expect(issuesApi.get).toHaveBeenCalledWith(issue.identifier!); + expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue); + }); + it("seeds both the detail snapshot and the first comments page for navigation", async () => { const issue = createIssue(); const firstPage = [createComment({ id: "c1" })]; diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 24fe25a5844..a6dd42c939f 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -17,9 +17,12 @@ const routerMock = vi.hoisted(() => ({ location: { pathname: "/", search: "", hash: "" }, navigate: vi.fn(), })); +const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" as string | null })); const externalObjectMocks = vi.hoisted(() => ({ summaries: new Map(), + error: null as Error | null, + retry: vi.fn(), })); const apiMocks = vi.hoisted(() => ({ @@ -103,7 +106,7 @@ vi.mock("../api/projects", () => ({ })); vi.mock("../context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1" }), + useCompany: () => companyState, })); vi.mock("../context/BreadcrumbContext", () => ({ @@ -136,7 +139,9 @@ vi.mock("../hooks/useIssueExternalObjects", () => ({ useIssueExternalObjectSummaries: () => ({ summaries: externalObjectMocks.summaries, isLoading: false, - isReady: true, + isReady: !externalObjectMocks.error, + error: externalObjectMocks.error, + refetch: externalObjectMocks.retry, }), })); @@ -338,8 +343,11 @@ function createFailedRun(overrides: Partial = {}): HeartbeatRun { } function resetInboxApiMocks() { + companyState.selectedCompanyId = "company-1"; for (const mock of Object.values(apiMocks)) mock.mockReset(); externalObjectMocks.summaries.clear(); + externalObjectMocks.error = null; + externalObjectMocks.retry.mockReset(); routerMock.location.pathname = "/"; routerMock.location.search = ""; routerMock.location.hash = ""; @@ -900,6 +908,135 @@ describe("Inbox toolbar", () => { }); }); + it("waits for the task index before revealing faster failed-run rows", async () => { + routerMock.location.pathname = "/inbox/mine"; + let release: (rows: Issue[]) => void = () => undefined; + apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "index" + ? new Promise((resolve) => { release = resolve; }) : Promise.resolve([])); + apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(async () => root.render()); + await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled()); + expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); + expect(container.querySelector('[data-inbox-item]')).toBeNull(); + await act(async () => release([createIssue({ title: "Delayed task index row" })])); + await vi.waitFor(() => expect(container.textContent).toContain("Delayed task index row")); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("can select a company after a cold inbox mount", async () => { + companyState.selectedCompanyId = null; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + const render = () => root.render(); + await act(render); + await vi.waitFor(() => expect(container.textContent).toContain("Select an organization")); + companyState.selectedCompanyId = "company-1"; + apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]); + await act(render); + await vi.waitFor(() => expect(container.textContent).toContain("Cold company task")); + act(() => root.unmount()); + }); + + 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" })); + apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.q && !filters.ids ? [] : rows)); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(() => root.render()); + await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30)); + await act(() => { + const input = container.querySelector('input[type="search"]')!; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "only-in-description"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ q: "only-in-description", ids: expect.arrayContaining(rows.map((row) => row.id)), projection: "index", limit: 100 }), expect.anything())); + await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30)); + expect(container.querySelectorAll('button[aria-label="Archive"]')).toHaveLength(30); + expect(container.textContent).not.toContain("Other matching tasks"); + act(() => root.unmount()); + }); + + it("keeps rows readable and offers retry when optional details fail", async () => { + routerMock.location.pathname = "/inbox/mine"; + let failDetails = true; + apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "rows" && failDetails + ? Promise.reject(new Error("Details unavailable")) : Promise.resolve([createIssue({ title: "Readable task" })])); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(() => root.render()); + await vi.waitFor(() => expect(document.body.textContent).toContain("Some task details couldn’t load.")); + expect(container.textContent).toContain("Readable task"); + failDetails = false; + await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry details")!.click()); + await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some task details couldn’t load.")); + expect(container.textContent).toContain("Readable task"); + act(() => root.unmount()); + }); + + it("refreshes diagnostics when a visible index row changes without changing its ID", async () => { + routerMock.location.pathname = "/inbox/mine"; + const row = createIssue({ title: "Live task", listProjection: "index" }); + apiMocks.issuesList.mockResolvedValue([row]); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(() => root.render()); + const detailsReads = () => apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "rows").length; + await vi.waitFor(() => expect(detailsReads()).toBe(1)); + const updated = { ...row, updatedAt: new Date(new Date(row.updatedAt).getTime() + 1000) }; + apiMocks.issuesList.mockResolvedValue([updated]); + await act(() => { + queryClient.setQueriesData({ predicate: (query) => query.queryKey.includes("index") }, [updated]); + }); + await vi.waitFor(() => expect(detailsReads()).toBe(2)); + expect(container.textContent).toContain("Live task"); + act(() => root.unmount()); + }); + + it.each(["approvalsList", "joinRequestsList", "heartbeatRunsList"] as const)( + "keeps available tasks visible when %s fails and during retry", async (source) => { + routerMock.location.pathname = "/inbox/mine"; + apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Available task" })]); + apiMocks[source].mockRejectedValue(new Error("Optional source unavailable")); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("Available task")); + expect(document.body.textContent).toContain("Some inbox items couldn’t load."); + let resolveRetry: (rows: []) => void = () => undefined; + apiMocks[source].mockImplementation(() => new Promise((resolve) => { resolveRetry = resolve; })); + await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry missing items")!.click()); + await vi.waitFor(() => expect(apiMocks[source]).toHaveBeenCalledTimes(2)); + expect(container.textContent).toContain("Available task"); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + await act(() => resolveRetry([])); + await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load.")); + act(() => root.unmount()); + }, + ); + + it("shows an error and retry instead of an endless skeleton when a required external status filter fails", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ + version: 1, companyId: "company-1", collectionKey: "inbox", columns: [], + viewState: { issueFilters: { externalObjectStatuses: ["failed"] } }, + })); + apiMocks.experimentalSettings.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true }); + apiMocks.issuesList.mockResolvedValue([createIssue()]); + externalObjectMocks.error = new Error("External statuses unavailable"); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const root = createRoot(container); + await act(() => root.render()); + await vi.waitFor(() => expect(container.textContent).toContain("External statuses unavailable")); + expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); + await act(() => Array.from(container.querySelectorAll('button')).find((button) => button.textContent === "Retry")!.click()); + expect(externalObjectMocks.retry).toHaveBeenCalledOnce(); + act(() => root.unmount()); + }); + it("requests live descendant summaries for issue rows", async () => { routerMock.location.pathname = "/inbox/mine"; @@ -916,20 +1053,11 @@ describe("Inbox toolbar", () => { ); }); - await vi.waitFor(() => { - expect(apiMocks.issuesList).toHaveBeenCalledTimes(3); - }); - - expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([ - true, - true, - true, - ]); - expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.limit)).toEqual([ - 500, - 500, - 500, - ]); + await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledTimes(1)); + expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ + touchedByUserId: "me", inboxArchivedByUserId: "me", projection: "index", + includeLiveDescendantSummary: true, limit: 100, sortField: "updated", sortDir: "desc", + })); act(() => { root.unmount(); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index c64b6fc4e3f..a477f931593 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,3 +1,5 @@ +import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; +import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -191,7 +193,7 @@ import { } from "../lib/task-date-groups"; const INBOX_HEARTBEAT_RUN_LIMIT = 200; -const INBOX_ISSUE_LIST_LIMIT = 500; +const INBOX_ISSUE_LIST_LIMIT = 100; const INBOX_HOT_PATH_STALE_MS = 30_000; const INBOX_COLLECTION_KEY = "inbox"; @@ -847,7 +849,7 @@ function StreamlinedInbox({ const location = useLocation(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const { data: experimentalSettings } = useQuery({ + const { data: experimentalSettings, isLoading: isSettingsLoading } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, @@ -865,7 +867,7 @@ function StreamlinedInbox({ () => loadInboxCollectionPreferences(selectedCompanyId).columns, ); const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts(); - const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId); + const { dismissedAtByKey, dismiss: dismissInboxItem, isLoading: isDismissalsLoading, error: dismissalsError } = useInboxDismissals(selectedCompanyId); const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences; @@ -890,18 +892,18 @@ function StreamlinedInbox({ [surfaceLabel, location.pathname, location.search, location.hash], ); - const { data: session } = useQuery({ + const { data: session, isLoading: isSessionLoading } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); - const { data: agents } = useQuery({ + const { data: agents, isLoading: isAgentsLoading } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); - const { data: projects } = useQuery({ + const { data: projects, isLoading: isProjectsLoading } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }), queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }), enabled: !!selectedCompanyId, @@ -913,7 +915,7 @@ function StreamlinedInbox({ }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; - const { data: executionWorkspaces = [] } = useQuery({ + const { data: executionWorkspaces = [], isLoading: isWorkspacesLoading } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) : ["execution-workspaces", "__disabled__"], @@ -956,6 +958,7 @@ function StreamlinedInbox({ const { data: joinRequests = [], isLoading: isJoinRequestsLoading, + error: joinRequestsError, } = useQuery({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!), queryFn: async () => { @@ -977,96 +980,104 @@ function StreamlinedInbox({ companyId: selectedCompanyId, resourceKey: "dashboard", queryKey: dashboardQueryKey, - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && tab === "all", }); const { data: dashboard, isLoading: isDashboardLoading, dataUpdatedAt: dashboardUpdatedAt } = useQuery({ queryKey: dashboardQueryKey, queryFn: () => dashboardApi.summary(selectedCompanyId!), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && tab === "all", }); usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt); - const inboxIssuesQueryKey = [...queryKeys.issues.list(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; - const sharedInboxIssues = useSharedPollingQuery({ - companyId: selectedCompanyId, - resourceKey: "inbox:issues", - queryKey: inboxIssuesQueryKey, - enabled: !!selectedCompanyId, - }); - const { data: issues, isLoading: isIssuesLoading, dataUpdatedAt: issuesUpdatedAt } = useQuery({ - queryKey: inboxIssuesQueryKey, - queryFn: () => - issuesApi.listCompact(selectedCompanyId!, { - includeRoutineExecutions: true, - includeLiveDescendantSummary: true, - limit: INBOX_ISSUE_LIST_LIMIT, - }).then((rows) => rows as Issue[]), - enabled: !!selectedCompanyId, - refetchOnWindowFocus: false, - staleTime: INBOX_HOT_PATH_STALE_MS, - }); - usePublishSharedQueryData(sharedInboxIssues, issues, issuesUpdatedAt); const { data: mineIssuesRaw = [], isLoading: isMineIssuesLoading, + error: mineIssuesError, dataUpdatedAt: mineIssuesUpdatedAt, } = useQuery({ - queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, + queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, queryFn: () => issuesApi.listCompact(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, + projection: "index", + sortField: "updated", + sortDir: "desc", includeRoutineExecutions: true, includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }).then((rows) => rows as Issue[]), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && tab === "mine", refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); - const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; + const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedMineIssues = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "inbox:mine-issues", queryKey: mineIssuesQueryKey, - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && tab === "mine", }); usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt); const { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, + error: touchedIssuesError, dataUpdatedAt: touchedIssuesUpdatedAt, } = useQuery({ - queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, + queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const, queryFn: () => issuesApi.listCompact(selectedCompanyId!, { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, + projection: "index", + sortField: "updated", + sortDir: "desc", includeRoutineExecutions: true, includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }).then((rows) => rows as Issue[]), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && (tab !== "mine" && tab !== "blocked" || normalizedSearchQuery.length > 0), refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); - const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; + const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const; const sharedTouchedIssues = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "inbox:touched-issues", queryKey: touchedIssuesQueryKey, - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && (tab !== "mine" && tab !== "blocked" || normalizedSearchQuery.length > 0), }); usePublishSharedQueryData(sharedTouchedIssues, touchedIssuesRaw, touchedIssuesUpdatedAt); - const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ - queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT], - queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true }), + const { data: heartbeatRuns, isLoading: isRunsLoading, error: runsError } = useQuery({ + queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "inbox-failures", INBOX_HEARTBEAT_RUN_LIMIT], + queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true, latestFailuresOnly: true }), enabled: !!selectedCompanyId, 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), + 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; + }, + enabled: !!selectedCompanyId && contextIssueIds.length > 0 && tab !== "blocked", + }); + const issues = useMemo(() => [...mineIssuesRaw, ...touchedIssuesRaw, ...contextIssues], [mineIssuesRaw, touchedIssuesRaw, contextIssues]); const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); const sharedLiveRuns = useSharedPollingQuery({ companyId: selectedCompanyId, @@ -1077,14 +1088,14 @@ function StreamlinedInbox({ refetchInterval: false, leaderOnly: true, }); - const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ + const { data: liveRuns, isLoading: isLiveRunsLoading, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ queryKey: liveRunsQueryKey, queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: sharedLiveRuns.enabled, refetchInterval: sharedLiveRuns.refetchInterval, }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); - const { data: companyMembers } = useQuery({ + const { data: companyMembers, isLoading: isMembersLoading } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, @@ -1119,6 +1130,22 @@ function StreamlinedInbox({ () => getRecentTouchedIssues(touchedIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)), [locallyArchivedIssueIds, touchedIssuesRaw], ); + const sourceSearchIds = useMemo(() => [...new Set([...mineIssuesRaw, ...touchedIssuesRaw].map((issue) => issue.id))].sort(), [mineIssuesRaw, touchedIssuesRaw]); + const { data: sourceSearchMatches = [], isLoading: isSourceSearchLoading, error: sourceSearchError } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "inbox-source-search", normalizedSearchQuery, sourceSearchIds], + queryFn: async ({ signal }) => { + const matches: Issue[] = []; + for (let offset = 0; offset < sourceSearchIds.length; offset += 100) { + matches.push(...await issuesApi.listCompact(selectedCompanyId!, { + ids: sourceSearchIds.slice(offset, offset + 100), q: normalizedSearchQuery, + projection: "index", includeRoutineExecutions: true, limit: 100, + }, { signal }) as Issue[]); + } + return matches; + }, + enabled: !!selectedCompanyId && normalizedSearchQuery.length > 0 && sourceSearchIds.length > 0, + }); + const sourceSearchMatchIds = useMemo(() => new Set(sourceSearchMatches.map((issue) => issue.id)), [sourceSearchMatches]); const shouldUseIssueSearchSupplement = !!selectedCompanyId && normalizedSearchQuery.length > 0; @@ -1161,6 +1188,8 @@ function StreamlinedInbox({ summaries: externalObjectSummaryByIssueId, isLoading: externalObjectSummariesLoading, isReady: externalObjectSummariesReady, + error: externalObjectSummariesError, + refetch: retryExternalObjectSummaries, } = useIssueExternalObjectSummaries( selectedCompanyId, inboxIssueIdsForExternalObjectSummaries, @@ -1403,6 +1432,7 @@ function StreamlinedInbox({ return workItemsToRender.filter((item) => { if (item.kind === "issue") { return matchesInboxIssueSearch(item.issue, q, { + matchedIssueIds: sourceSearchMatchIds, isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, @@ -1446,6 +1476,7 @@ function StreamlinedInbox({ issueById, isolatedWorkspacesEnabled, normalizedSearchQuery, + sourceSearchMatchIds, projectWorkspaceById, ]); @@ -1456,6 +1487,7 @@ function StreamlinedInbox({ visibleIssues: visibleMineIssues, searchableIssues: visibleTouchedIssues, query: normalizedSearchQuery, + matchedIssueIds: sourceSearchMatchIds, isolatedWorkspacesEnabled, executionWorkspaceById, projectWorkspaceById, @@ -1467,6 +1499,7 @@ function StreamlinedInbox({ executionWorkspaceById, isolatedWorkspacesEnabled, normalizedSearchQuery, + sourceSearchMatchIds, projectWorkspaceById, tab, visibleMineIssues, @@ -2391,10 +2424,6 @@ function StreamlinedInbox({ if (row) row.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); - if (!selectedCompanyId) { - return ; - } - const hasRunFailures = failedRuns.length > 0; const showCompanyAlerts = shouldShowCompanyAlerts(tab) && showAlertsCategory; const showAggregateAgentError = @@ -2425,16 +2454,46 @@ function StreamlinedInbox({ showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); - const allLoaded = + const initialSourcesSettled = + !isSourceSearchLoading && !isSessionLoading && !isSettingsLoading && !isDismissalsLoading && !isAgentsLoading && !isProjectsLoading && !isMembersLoading && !isWorkspacesLoading && !isJoinRequestsLoading && !isApprovalsLoading && !isDashboardLoading && - !isIssuesLoading && + !isContextIssuesLoading && !isMineIssuesLoading && !isTouchedIssuesLoading && - !isRunsLoading; + !isRunsLoading + && (!issueFilters.liveOnly || !isLiveRunsLoading) + && (issueFilters.externalObjectStatuses.length === 0 || externalObjectSummariesReady || !!externalObjectSummariesError); + const listError = sourceSearchError ?? (tab === "mine" ? mineIssuesError : touchedIssuesError) + ?? (issueFilters.externalObjectStatuses.length > 0 ? externalObjectSummariesError : null); + const optionalSourceError = dismissalsError ?? approvalsError ?? joinRequestsError ?? runsError ?? contextIssuesError; + const retryFailedSources = () => { + // Retry only failed reads for this company; settled rows stay visible. + void queryClient.refetchQueries({ predicate: (query) => query.state.status === "error" + && query.queryKey.includes(selectedCompanyId), type: "active" }); + }; + + const revealScope = JSON.stringify([selectedCompanyId, tab, normalizedSearchQuery, issueFilters]); + const [revealedScope, setRevealedScope] = useState(null); + useEffect(() => { + if (initialSourcesSettled && !listError) setRevealedScope(revealScope); + }, [initialSourcesSettled, listError, revealScope]); + const allLoaded = initialSourcesSettled || revealedScope === revealScope; const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; + const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( + selectedCompanyId, listRef, + groupedSections.map((group) => group.key + ":" + group.displayItems.map(getInboxWorkItemKey).join(",") + ":" + [...group.childrenByIssueId.values()].flat().map((issue) => issue.id).join(",")).join("|") + + ":" + [...collapsedInboxParents].join(",") + ":" + [...collapsedGroupKeys].join(","), + allLoaded && !listError && tab !== "blocked", + issues, + ); + + if (!selectedCompanyId) { + return ; + } + const markAllReadIssues = (tab === "mine" ? visibleMineIssues : unreadTouchedIssues) .filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id)); const unreadIssueIds = markAllReadIssues @@ -2459,6 +2518,12 @@ function StreamlinedInbox({ : null; return (
+ void retryRowDetails()} + /> - {approvalsError &&

{approvalsError.message}

} + {listError && ( +
+

Unable to load the inbox: {listError.message}

+ +
+ )} {actionError &&

{actionError}

} {tab === "blocked" ? ( @@ -2770,11 +2840,11 @@ function StreamlinedInbox({
) : null} - {tab !== "blocked" && !allLoaded && visibleSections.length === 0 && ( - + {tab !== "blocked" && !allLoaded && !listError && ( + )} - {tab !== "blocked" && allLoaded && visibleSections.length === 0 && ( + {tab !== "blocked" && allLoaded && !listError && !optionalSourceError && visibleSections.length === 0 && ( )} - {tab !== "blocked" && showWorkItemsSection && ( + {tab !== "blocked" && allLoaded && !listError && showWorkItemsSection && ( <> {showSeparatorBefore("work_items") && }
@@ -2821,6 +2891,7 @@ function StreamlinedInbox({ collapseParentId?: string | null; allowArchive?: boolean; }) => { + issue = enrichIssueRow(issue); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id); @@ -2853,6 +2924,7 @@ function StreamlinedInbox({ key={`issue:${issue.id}`} issue={issue} presentation={streamlinedUiEnabled ? "task" : "legacy"} + stableLayout issueLinkState={issueLinkState} treeGuides={depth} chevronInGuide={streamlinedUiEnabled && depth > 0 && hasChildren} @@ -3323,7 +3395,7 @@ function StreamlinedInbox({ )} - {showAlertsSection && ( + {allLoaded && !listError && showAlertsSection && ( <> {showSeparatorBefore("alerts") && }
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 9686be17c48..58cb73c9827 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -257,13 +257,14 @@ function OrganizationIssues({ participantAgentId ?? "__all__", "workspace", workspaceIdFilter ?? "__all__", - "compact", + streamlinedUiEnabled ? "index" : "compact", "with-routine-executions", showAgentCreatedTasks, "infinite", issuePageSize, ], queryFn: ({ pageParam, signal }) => issuesApi.listCompact(selectedCompanyId!, { + projection: streamlinedUiEnabled ? "index" : undefined, participantAgentId, workspaceId: workspaceIdFilter, includeRoutineExecutions: true,