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()} /> );