From 2f94a66ba123b77ed9bd381e003d5e6bc8025d42 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:22:33 -0500 Subject: [PATCH] Show live descendant status in inbox rows (#8876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The inbox is where operators quickly scan which issues are active, blocked, or waiting for attention > - A blocked parent can still have active descendant work, but the inbox previously depended on only loaded rows to infer that state > - That made collapsed or partially loaded issue trees look more stuck than they really were > - This pull request carries live descendant summary data through the issue list API and inbox UI > - The benefit is a more accurate blocked-inbox signal, so operators can distinguish truly stalled work from blocked parents that still have live child activity ## Linked Issues or Issue Description No public GitHub issue was found for this exact inbox descendant-status polish. Feature request fields: **Subsystem affected** Cross-cutting: `server/`, `packages/shared`, plugin/MCP API surfaces, and `ui/` inbox rendering. **Problem or motivation** Inbox rows need to show when blocked or collapsed parents still have live descendant work, even when the live child row is not loaded in the current client tree. Without a server-provided descendant summary, a parent can look stalled even though active work continues below it. **Proposed solution** Expose an optional live descendant count on issue list results, request it from inbox views, and use it to render covered blocked status and live-below indicators. Keep the field opt-in so other issue list callers keep their existing payload shape and query cost. **Alternatives considered** Relying only on client-loaded subtree state was ruled out because it misses collapsed or unloaded descendants. Always returning the count was also avoided because most list callers do not need this extra summary. **Roadmap alignment** This is scoped operator-visibility polish for the existing inbox. It does not duplicate a named `ROADMAP.md` milestone. **Additional context** The recursive summary query is guarded against parent cycles, and the UI still falls back to loaded subtree live counts when server summary data is absent or stale. ## What Changed - Added optional `includeLiveDescendantSummary` support to issue list contracts, SDK surfaces, MCP tools, routes, services, and tests. - Added `liveDescendantCount` to issue list results when requested. - Updated inbox and blocked-inbox queries to request live descendant summaries. - Updated inbox row status rendering so blocked parents with live descendants show covered blocker treatment without duplicating the live-below chip. - Hardened live descendant summary traversal against parent cycles and preserved the loaded-subtree fallback path for blocked inbox rows. - Added focused tests for the API parameter, service behavior, helper logic, cycle handling, and inbox UI query/rendering behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-list-assignee-filter-routes.test.ts ui/src/lib/inbox-live-descendants.test.ts ui/src/components/IssueColumns.test.tsx ui/src/components/BlockedInboxView.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - Rebased cleanly onto current upstream `master` before pushing. - Confirmed the branch diff does not include `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Risks Low to moderate risk. The new descendant count is opt-in on list requests, but it adds query work when the inbox asks for it. The recursive traversal now tracks visited ancestors to avoid cycle failures. The UI uses the server count as a supplement to existing loaded-tree state, so stale or absent counts fall back to the prior behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent, tool-enabled with local shell and git access. Reasoning mode and context window are managed by the Paperclip/Codex runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/mcp-server/src/tools.ts | 1 + packages/plugins/sdk/src/ui/components.ts | 1 + packages/shared/src/types/issue.ts | 1 + .../issue-list-assignee-filter-routes.test.ts | 228 +++++++++++++++++- server/src/routes/issues.ts | 6 + server/src/services/issues.ts | 95 +++++++- ui/src/api/issues.test.ts | 8 + ui/src/api/issues.ts | 2 + ui/src/components/BlockedInboxView.test.tsx | 40 ++- ui/src/components/BlockedInboxView.tsx | 25 +- ui/src/components/IssueColumns.test.tsx | 12 + ui/src/components/IssueColumns.tsx | 4 +- ui/src/lib/inbox-live-descendants.test.ts | 90 +++++++ ui/src/lib/inbox-live-descendants.ts | 55 +++++ ui/src/pages/Inbox.test.tsx | 52 +++- ui/src/pages/Inbox.tsx | 44 +++- .../stories/blocked-inbox.stories.tsx | 2 + 17 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 ui/src/lib/inbox-live-descendants.test.ts create mode 100644 ui/src/lib/inbox-live-descendants.ts diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 28dbda8af5..f5772b33a4 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -74,6 +74,7 @@ const listIssuesSchema = z.object({ originKind: z.string().optional(), originId: z.string().optional(), includeRoutineExecutions: z.boolean().optional(), + includeLiveDescendantSummary: z.boolean().optional(), q: z.string().optional(), }); diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts index b075506cd1..3f0594408a 100644 --- a/packages/plugins/sdk/src/ui/components.ts +++ b/packages/plugins/sdk/src/ui/components.ts @@ -338,6 +338,7 @@ export interface IssuesListFilters { originId?: string; descendantOf?: string; includeRoutineExecutions?: boolean; + includeLiveDescendantSummary?: boolean; } export interface IssuesListProps { diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index c676cafb3b..6dc6d44b67 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -585,6 +585,7 @@ export interface Issue { successfulRunHandoff?: SuccessfulRunHandoffState | null; watchdog?: IssueWatchdogSummary | null; scheduledRetry?: IssueScheduledRetry | null; + liveDescendantCount?: number; relatedWork?: IssueRelatedWorkSummary; referencedIssueIdentifiers?: string[]; planDocument?: IssueDocument | null; 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 977de540f7..e3b119c2c6 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, companyMemberships, createDb, issues, principalPermissionGrants } from "@paperclipai/db"; +import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -31,6 +31,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { afterEach(async () => { await db.delete(issues); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); @@ -220,4 +221,229 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { error: "assigneeAgentId must be a UUID or 'null'", }); }); + + it("returns opt-in live descendant counts for offscreen live descendants only", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const grandchildIssueId = randomUUID(); + const hiddenChildIssueId = randomUUID(); + const crossCompanyChildIssueId = randomUUID(); + const rootRunId = randomUUID(); + const grandchildRunId = randomUUID(); + const hiddenRunId = randomUUID(); + const crossCompanyRunId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other Company", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }, + ]); + await seedCloudTenantMember(companyId); + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId: otherCompanyId, + name: "Other", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(heartbeatRuns).values([ + { + id: rootRunId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: rootIssueId }, + }, + { + id: grandchildRunId, + companyId, + agentId, + status: "queued", + contextSnapshot: { issueId: grandchildIssueId }, + }, + { + id: hiddenRunId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: hiddenChildIssueId }, + }, + { + id: crossCompanyRunId, + companyId: otherCompanyId, + agentId: otherAgentId, + status: "running", + contextSnapshot: { issueId: crossCompanyChildIssueId }, + }, + ]); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Blocked parent", + status: "blocked", + priority: "critical", + executionRunId: rootRunId, + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + title: "Offscreen child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + assigneeAgentId: agentId, + }, + { + id: grandchildIssueId, + companyId, + title: "Offscreen live grandchild", + status: "todo", + priority: "medium", + parentId: childIssueId, + executionRunId: grandchildRunId, + assigneeAgentId: agentId, + }, + { + id: hiddenChildIssueId, + companyId, + title: "Hidden live child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + executionRunId: hiddenRunId, + hiddenAt: new Date("2026-07-02T00:00:00.000Z"), + assigneeAgentId: agentId, + }, + { + id: crossCompanyChildIssueId, + companyId: otherCompanyId, + title: "Cross-company live child", + status: "todo", + priority: "medium", + parentId: rootIssueId, + executionRunId: crossCompanyRunId, + assigneeAgentId: otherAgentId, + }, + ]); + + const app = createApp(companyId); + const withoutSummary = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", limit: "20" }); + + expect(withoutSummary.status, JSON.stringify(withoutSummary.body)).toBe(200); + expect(withoutSummary.body).toHaveLength(1); + expect(withoutSummary.body[0].id).toBe(rootIssueId); + expect(withoutSummary.body[0].liveDescendantCount).toBeUndefined(); + + const withSummary = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" }); + + expect(withSummary.status, JSON.stringify(withSummary.body)).toBe(200); + expect(withSummary.body).toHaveLength(1); + expect(withSummary.body[0]).toMatchObject({ + id: rootIssueId, + liveDescendantCount: 1, + }); + }); + + it("does not recurse forever when live descendant summaries encounter a parent cycle", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const parentIssueId = randomUUID(); + const childIssueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + contextSnapshot: { issueId: childIssueId }, + }); + await db.insert(issues).values([ + { + id: parentIssueId, + companyId, + title: "Cycle parent", + status: "blocked", + priority: "medium", + parentId: childIssueId, + assigneeAgentId: agentId, + }, + { + id: childIssueId, + companyId, + title: "Cycle live child", + status: "in_progress", + priority: "medium", + parentId: parentIssueId, + executionRunId: runId, + assigneeAgentId: agentId, + }, + ]); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: parentIssueId, + liveDescendantCount: 1, + }); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8e0889c619..4262972f14 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3067,6 +3067,7 @@ export function issueRoutes( const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); + const includeLiveDescendantSummary = parseOptionalBooleanQuery(req.query.includeLiveDescendantSummary); const assigneeAgentFilterRaw = req.query.assigneeAgentId; let assigneeAgentId: string | null | undefined; @@ -3110,6 +3111,10 @@ export function issueRoutes( res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); return; } + if (includeLiveDescendantSummary === null) { + res.status(400).json({ error: "includeLiveDescendantSummary must be true or false when provided" }); + return; + } if (assigneeAgentFilterRaw !== undefined) { if (typeof assigneeAgentFilterRaw !== "string") { res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" }); @@ -3156,6 +3161,7 @@ export function issueRoutes( includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", + includeLiveDescendantSummary: includeLiveDescendantSummary === true, hasPlanDocument, q: req.query.q as string | undefined, limit, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 686caa0bff..0fa0913587 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -319,6 +319,7 @@ export interface IssueFilters { includePluginOperations?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; lowTrustBoundary?: LowTrustBoundary & { companyId: string }; q?: string; @@ -1481,6 +1482,85 @@ async function activeRunMapForIssues( return map; } +async function liveDescendantCountMapForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise> { + const uniqueIssueIds = [...new Set(issueIds)]; + const map = new Map(); + if (uniqueIssueIds.length === 0) return map; + + for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`); + const rows = await dbOrTx.execute(sql<{ + issueId: string; + liveDescendantCount: number; + }>` + WITH RECURSIVE + target_issues(issue_id) AS ( + VALUES ${sql.join(targetRows, sql`, `)} + ), + live_issues(live_issue_id, parent_id) AS ( + SELECT DISTINCT live_issue.id, live_issue.parent_id + FROM issues live_issue + JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id + WHERE live_issue.company_id = ${companyId} + AND live_issue.hidden_at IS NULL + AND live_run.company_id = ${companyId} + AND live_run.status IN ('queued', 'running') + UNION + SELECT DISTINCT live_issue.id, live_issue.parent_id + FROM heartbeat_runs live_run + JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId') + WHERE live_issue.company_id = ${companyId} + AND live_issue.hidden_at IS NULL + AND live_run.company_id = ${companyId} + AND live_run.status IN ('queued', 'running') + ), + live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS ( + SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id] + FROM live_issues + JOIN issues parent ON parent.id = live_issues.parent_id + WHERE parent.company_id = ${companyId} + AND parent.hidden_at IS NULL + UNION ALL + SELECT + live_ancestors.live_issue_id, + parent.id, + parent.parent_id, + live_ancestors.visited_issue_ids || parent.id + FROM live_ancestors + JOIN issues parent ON parent.id = live_ancestors.next_parent_id + WHERE parent.company_id = ${companyId} + AND parent.hidden_at IS NULL + AND NOT parent.id = ANY(live_ancestors.visited_issue_ids) + ) + SELECT + live_ancestors.ancestor_id::text AS "issueId", + count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount" + FROM live_ancestors + JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id + WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id + GROUP BY live_ancestors.ancestor_id + `); + + const resultRows = Array.isArray(rows) ? rows : Array.from(rows as Iterable); + for (const row of resultRows) { + if (typeof row !== "object" || row === null) continue; + const issueId = (row as { issueId?: unknown }).issueId; + const liveDescendantCount = (row as { liveDescendantCount?: unknown }).liveDescendantCount; + if (typeof issueId !== "string") continue; + const count = typeof liveDescendantCount === "number" + ? liveDescendantCount + : Number(liveDescendantCount); + if (Number.isFinite(count)) map.set(issueId, count); + } + } + + return map; +} + function createIssueBlockerAttention(input: Partial = {}): IssueBlockerAttention { return { state: input.state ?? "none", @@ -3170,6 +3250,7 @@ async function listBlockedInboxIssues( blockerAttention?: IssueBlockerAttention; blockedInboxAttention: IssueBlockedInboxAttention; productivityReview?: IssueProductivityReview | null; + liveDescendantCount?: number; lastActivityAt: Date; myLastTouchAt?: Date | null; lastExternalCommentAt?: Date | null; @@ -3191,6 +3272,7 @@ async function listBlockedInboxIssues( if (withRuns.length === 0) return []; const issueIds = withRuns.map((row) => row.id); + const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const [ statsRows, readRows, @@ -3199,6 +3281,7 @@ async function listBlockedInboxIssues( blockerAttentionByIssueId, productivityReviewByIssueId, blockedInboxAttentionByIssueId, + liveDescendantCountByIssueId, ] = await Promise.all([ contextUserId ? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId ? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -3207,6 +3290,9 @@ async function listBlockedInboxIssues( listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns), listIssueProductivityReviewMap(dbOrTx, companyId, issueIds), listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns), + includeLiveDescendantSummary + ? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds) + : Promise.resolve(new Map()), ]); const rawSearchInput = filters?.q?.trim() ?? ""; @@ -3256,6 +3342,7 @@ async function listBlockedInboxIssues( ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(contextUserId ? deriveIssueUserContext(row, contextUserId, { myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, @@ -4289,6 +4376,7 @@ export function issueService(db: Db) { const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const includeBlockedBy = filters?.includeBlockedBy === true; const includeBlockedInboxAttention = filters?.includeBlockedInboxAttention === true; + const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -4436,7 +4524,7 @@ export function issueService(db: Db) { } const issueIds = withRuns.map((row) => row.id); - const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([ + const [statsRows, readRows, lastActivityRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -4447,6 +4535,9 @@ export function issueService(db: Db) { includeBlockedBy ? blockedByMapForIssues(db, companyId, issueIds) : Promise.resolve(new Map()), + includeLiveDescendantSummary + ? liveDescendantCountMapForIssues(db, companyId, issueIds) + : Promise.resolve(new Map()), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -4476,6 +4567,7 @@ export function issueService(db: Db) { lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -4498,6 +4590,7 @@ export function issueService(db: Db) { lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), + ...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 91f736f668..24f4794bcf 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -71,6 +71,14 @@ describe("issuesApi.list", () => { ); }); + it("passes live descendant summary opt-in through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { includeLiveDescendantSummary: true, limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?includeLiveDescendantSummary=true&limit=25", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 129b7b0ba6..1ecb12afa0 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -60,6 +60,7 @@ export const issuesApi = { includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + includeLiveDescendantSummary?: boolean; hasPlanDocument?: boolean; q?: string; limit?: number; @@ -89,6 +90,7 @@ export const issuesApi = { if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true"); + if (filters?.includeLiveDescendantSummary) params.set("includeLiveDescendantSummary", "true"); if (filters?.hasPlanDocument !== undefined) { params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false"); } diff --git a/ui/src/components/BlockedInboxView.test.tsx b/ui/src/components/BlockedInboxView.test.tsx index 328ceb91fd..98e44bf1ff 100644 --- a/ui/src/components/BlockedInboxView.test.tsx +++ b/ui/src/components/BlockedInboxView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared"; @@ -31,6 +31,14 @@ vi.mock("@/lib/router", () => ({ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + import { BlockedInboxView } from "./BlockedInboxView"; import { defaultIssueFilterState } from "../lib/issue-filters"; @@ -121,6 +129,7 @@ const blockedViewProps = { issueFilters: defaultIssueFilterState, currentUserId: "local-board", liveIssueIds: new Set(), + subtreeLiveCounts: new Map(), workspaceFilterContext: {}, showStatusColumn: true, showIdentifierColumn: true, @@ -306,6 +315,35 @@ describe("BlockedInboxView", () => { act(() => root.unmount()); }); + it("uses loaded live descendants when blocked inbox rows do not have a server summary", async () => { + mockIssuesApi.list.mockResolvedValue([ + { + ...makeIssue( + "blocked-parent", + "PAP-77", + "Blocked parent with active child", + attention({ reason: "blocked_chain_stalled" }), + ), + status: "blocked", + blockerAttention: null, + liveDescendantCount: undefined, + } as unknown as Issue, + ]); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelector("a") !== null); + + expect(container.querySelector('[aria-label="Blocked · waiting on 1 active sub-task"]')).not.toBeNull(); + + act(() => root.unmount()); + }); + it("renders the visible error banner with retry when the query fails", async () => { mockIssuesApi.list.mockRejectedValue(new Error("network down")); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index a8220930e6..1a5565823f 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -6,6 +6,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; import { applyIssueFilters, type IssueFilterState, type IssueFilterWorkspaceContext } from "../lib/issue-filters"; +import { resolveInboxIssueBlockerAttention } from "../lib/inbox-live-descendants"; import { blockedRowMatchesSearch, buildBlockedInboxRows, @@ -34,6 +35,7 @@ interface BlockedInboxViewProps { issueFilters: IssueFilterState; currentUserId: string | null; liveIssueIds: ReadonlySet; + subtreeLiveCounts: ReadonlyMap; workspaceFilterContext: IssueFilterWorkspaceContext; showStatusColumn: boolean; showIdentifierColumn: boolean; @@ -53,6 +55,7 @@ export function BlockedInboxView({ issueFilters, currentUserId, liveIssueIds, + subtreeLiveCounts, workspaceFilterContext, showStatusColumn, showIdentifierColumn, @@ -67,12 +70,13 @@ export function BlockedInboxView({ error, refetch, } = useQuery({ - queryKey: queryKeys.issues.listBlockedAttention(companyId), + queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary"], queryFn: () => issuesApi.list(companyId, { attention: "blocked", includeBlockedInboxAttention: true, includeBlockedBy: true, + includeLiveDescendantSummary: true, limit: BLOCKED_LIST_LIMIT, }), }); @@ -209,6 +213,8 @@ export function BlockedInboxView({ issueLinkState={issueLinkState} agentNameById={agentNameById} userLabelById={userLabelById} + liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} showStatusColumn={showStatusColumn} showIdentifierColumn={showIdentifierColumn} showUpdatedColumn={showUpdatedColumn} @@ -236,6 +242,8 @@ export function BlockedInboxView({ issueLinkState={issueLinkState} agentNameById={agentNameById} userLabelById={userLabelById} + liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} showStatusColumn={showStatusColumn} showIdentifierColumn={showIdentifierColumn} showUpdatedColumn={showUpdatedColumn} @@ -257,6 +265,8 @@ interface BlockedInboxRowProps { issueLinkState: unknown; agentNameById: ReadonlyMap; userLabelById?: ReadonlyMap; + liveIssueIds: ReadonlySet; + subtreeLiveCounts: ReadonlyMap; showStatusColumn: boolean; showIdentifierColumn: boolean; showUpdatedColumn: boolean; @@ -283,12 +293,18 @@ function BlockedInboxRow({ issueLinkState, agentNameById, userLabelById, + liveIssueIds, + subtreeLiveCounts, showStatusColumn, showIdentifierColumn, showUpdatedColumn, }: BlockedInboxRowProps) { const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById); const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt); + const blockerAttention = resolveInboxIssueBlockerAttention(row.issue, { + isLive: liveIssueIds.has(row.issue.id), + loadedSubtreeLiveCount: subtreeLiveCounts.get(row.issue.id) ?? 0, + }); const desktopTrailing = ( @@ -345,13 +361,14 @@ function BlockedInboxRow({ desktopMetaLeading={ } mobileLeading={ - + } titleSuffix={ @@ -369,17 +386,19 @@ function BlockedInboxRow({ function BlockedRowDesktopMeta({ row, + blockerAttention, showStatusColumn, showIdentifierColumn, }: { row: BlockedInboxIssueRow; + blockerAttention: Issue["blockerAttention"] | null; showStatusColumn: boolean; showIdentifierColumn: boolean; }) { const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8); return ( - {showStatusColumn ? : null} + {showStatusColumn ? : null} {showIdentifierColumn ? {identifier} : null} ); diff --git a/ui/src/components/IssueColumns.test.tsx b/ui/src/components/IssueColumns.test.tsx index 366a571963..9b01df2066 100644 --- a/ui/src/components/IssueColumns.test.tsx +++ b/ui/src/components/IssueColumns.test.tsx @@ -67,6 +67,18 @@ describe("InboxIssueMetaLeading live state", () => { expect(text).not.toMatch(/(^|[^a-z])Live([^a-z]|$)/); }); + it("can suppress the subtree chip when the status glyph already carries descendant liveness", () => { + const text = renderLeading( + , + ); + expect(text).not.toContain("live below"); + }); + it("renders no live treatment when the issue and its subtree are idle", () => { const text = renderLeading( )} - {!isLive && subtreeLiveCount > 0 && ( + {showSubtreeLiveChip && !isLive && subtreeLiveCount > 0 && ( ): Issue { + return { + id: "issue-1", + status: "blocked", + blockerAttention: null, + liveDescendantCount: 0, + ...overrides, + } as unknown as Issue; +} + +function makeBlockerAttention( + overrides: Partial = {}, +): IssueBlockerAttention { + return { + state: "none", + reason: null, + unresolvedBlockerCount: 0, + coveredBlockerCount: 0, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: null, + sampleStalledBlockerIdentifier: null, + ...overrides, + }; +} + +describe("inbox live descendant status helpers", () => { + it("combines server and loaded live descendant counts without double-counting", () => { + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 3 }), 1)).toBe(3); + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 0 }), 2)).toBe(2); + expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: -1 }), 2.7)).toBe(2); + }); + + it("synthesizes covered blocker attention for a blocked row with live descendants", () => { + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ liveDescendantCount: 2 }), + { isLive: false }, + ); + + expect(attention).toMatchObject({ + state: "covered", + reason: "active_child", + coveredBlockerCount: 2, + }); + }); + + it("uses loaded live descendants when the server count is absent", () => { + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ liveDescendantCount: undefined }), + { isLive: false, loadedSubtreeLiveCount: 1 }, + ); + + expect(attention?.state).toBe("covered"); + expect(attention?.coveredBlockerCount).toBe(1); + }); + + it("keeps urgent blocked attention red even when descendants are live", () => { + for (const state of ["needs_attention", "stalled"] as const) { + const original = makeBlockerAttention({ state, reason: "attention_required" }); + const attention = resolveInboxIssueBlockerAttention( + makeIssue({ blockerAttention: original, liveDescendantCount: 4 }), + { isLive: false }, + ); + + expect(attention).toBe(original); + } + }); + + it("does not synthesize covered attention for the live row itself or non-blocked parents", () => { + expect( + resolveInboxIssueBlockerAttention( + makeIssue({ status: "blocked", liveDescendantCount: 2 }), + { isLive: true }, + ), + ).toBeNull(); + expect( + resolveInboxIssueBlockerAttention( + makeIssue({ status: "done", liveDescendantCount: 2 }), + { isLive: false }, + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/lib/inbox-live-descendants.ts b/ui/src/lib/inbox-live-descendants.ts new file mode 100644 index 0000000000..36f53a6ede --- /dev/null +++ b/ui/src/lib/inbox-live-descendants.ts @@ -0,0 +1,55 @@ +import type { Issue, IssueBlockerAttention } from "@paperclipai/shared"; + +type InboxLiveDescendantIssue = Pick; + +interface InboxLiveDescendantOptions { + isLive: boolean; + loadedSubtreeLiveCount?: number; +} + +function normalizeLiveDescendantCount(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +function asBlockerAttention(value: unknown): IssueBlockerAttention | null { + if (!value || typeof value !== "object") return null; + const attention = value as Partial; + return typeof attention.state === "string" ? attention as IssueBlockerAttention : null; +} + +export function resolveIssueLiveDescendantCount( + issue: Pick, + loadedSubtreeLiveCount = 0, +): number { + return Math.max( + normalizeLiveDescendantCount(issue.liveDescendantCount), + normalizeLiveDescendantCount(loadedSubtreeLiveCount), + ); +} + +export function resolveInboxIssueBlockerAttention( + issue: InboxLiveDescendantIssue, + options: InboxLiveDescendantOptions, +): IssueBlockerAttention | null { + const blockerAttention = asBlockerAttention(issue.blockerAttention); + if (issue.status !== "blocked" || options.isLive) return blockerAttention; + if (blockerAttention?.state === "needs_attention" || blockerAttention?.state === "stalled") { + return blockerAttention; + } + if (blockerAttention?.state === "covered") return blockerAttention; + + const liveDescendantCount = resolveIssueLiveDescendantCount(issue, options.loadedSubtreeLiveCount); + if (liveDescendantCount <= 0) return blockerAttention; + + return { + state: "covered", + reason: "active_child", + unresolvedBlockerCount: blockerAttention?.unresolvedBlockerCount ?? 0, + coveredBlockerCount: liveDescendantCount, + stalledBlockerCount: blockerAttention?.stalledBlockerCount ?? 0, + attentionBlockerCount: blockerAttention?.attentionBlockerCount ?? 0, + sampleBlockerIdentifier: blockerAttention?.sampleBlockerIdentifier ?? null, + sampleStalledBlockerIdentifier: blockerAttention?.sampleStalledBlockerIdentifier ?? null, + }; +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index b59c8f9c25..fcf9a0b485 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ComponentProps } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; @@ -138,6 +138,14 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + // jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect. if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; @@ -227,6 +235,7 @@ function createJoinRequest( } function resetInboxApiMocks() { + for (const mock of Object.values(apiMocks)) mock.mockReset(); routerMock.location.pathname = "/"; routerMock.location.search = ""; routerMock.location.hash = ""; @@ -327,6 +336,37 @@ describe("Inbox toolbar", () => { }); }); + it("requests live descendant summaries for issue rows", async () => { + routerMock.location.pathname = "/inbox/mine"; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await vi.waitFor(() => { + expect(apiMocks.issuesList).toHaveBeenCalledTimes(3); + }); + + expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([ + true, + true, + true, + ]); + + act(() => { + root.unmount(); + }); + }); + it("syncs hover with j/k selection on inbox rows", async () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); @@ -360,18 +400,24 @@ describe("Inbox toolbar", () => { await act(async () => { rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); // After hovering row 1, that row is "selected" — same visual state as j/k selection. - expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent"); + await vi.waitFor(() => { + expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent"); + }); expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); await act(async () => { rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + rows[0]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); // Hovering a different row moves the selection to follow the mouse. - expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent"); + await vi.waitFor(() => { + expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent"); + }); expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); act(() => { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 63a7785112..4ad60fcbb9 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -49,6 +49,10 @@ import { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; +import { + resolveInboxIssueBlockerAttention, + resolveIssueLiveDescendantCount, +} from "../lib/inbox-live-descendants"; import { EmptyState } from "../components/EmptyState"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { PageSkeleton } from "../components/PageSkeleton"; @@ -798,10 +802,11 @@ export function Inbox() { }); const { data: issues, isLoading: isIssuesLoading } = useQuery({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -812,13 +817,14 @@ export function Inbox() { data: mineIssuesRaw = [], isLoading: isMineIssuesLoading, } = useQuery({ - queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", inboxArchivedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -829,12 +835,13 @@ export function Inbox() { data: touchedIssuesRaw = [], isLoading: isTouchedIssuesLoading, } = useQuery({ - queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"], + queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions", "live-descendant-summary"], queryFn: () => issuesApi.list(selectedCompanyId!, { touchedByUserId: "me", status: INBOX_MINE_ISSUE_STATUS_FILTER, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, limit: INBOX_ISSUE_LIST_LIMIT, }), enabled: !!selectedCompanyId, @@ -881,12 +888,14 @@ export function Inbox() { queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), "inbox-supplement", + "live-descendant-summary", ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedSearchQuery, limit: 25, includeRoutineExecutions: true, + includeLiveDescendantSummary: true, }), enabled: shouldUseIssueSearchSupplement, placeholderData: (previousData) => previousData, @@ -2312,6 +2321,7 @@ export function Inbox() { issueFilters={issueFilters} currentUserId={currentUserId} liveIssueIds={liveIssueIds} + subtreeLiveCounts={subtreeLiveCounts} workspaceFilterContext={inboxWorkspaceGrouping} showStatusColumn={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")} showIdentifierColumn={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} @@ -2372,6 +2382,22 @@ export function Inbox() { const assigneeUserProfile = issue.assigneeUserId ? companyUserProfileMap.get(issue.assigneeUserId) ?? null : null; + const isLive = liveIssueIds.has(issue.id); + const loadedSubtreeLiveCount = subtreeLiveCounts.get(issue.id) ?? 0; + const liveDescendantCount = resolveIssueLiveDescendantCount(issue, loadedSubtreeLiveCount); + const blockerAttention = resolveInboxIssueBlockerAttention(issue, { + isLive, + loadedSubtreeLiveCount, + }); + const showStatus = visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status"); + const showSubtreeLiveChip = !( + showStatus + && issue.status === "blocked" + && blockerAttention?.state === "covered" + ); + const rowStatusIcon = ( + + ); return ( 0 ? : null} } @@ -2433,7 +2461,9 @@ export function Inbox() { > - ) : undefined + ) : ( + + ) } unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} onMarkRead={() => markReadMutation.mutate(issue.id)} diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/blocked-inbox.stories.tsx index 031d1fb3e8..6aaf17ed9f 100644 --- a/ui/storybook/stories/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/blocked-inbox.stories.tsx @@ -187,6 +187,7 @@ function BlockedTabSurface({ search = "" }: { search?: string }) { searchQuery={search} agentNameById={new Map()} issueLinkState={null} + subtreeLiveCounts={new Map()} /> @@ -259,6 +260,7 @@ function BlockedTabEmptyState() { searchQuery="" agentNameById={new Map()} issueLinkState={null} + subtreeLiveCounts={new Map()} /> );