Show live descendant status in inbox rows (#8876)

## 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
This commit is contained in:
Dotta 2026-07-02 10:22:33 -05:00 committed by GitHub
parent b4815bf964
commit 2f94a66ba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 649 additions and 17 deletions

View File

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

View File

@ -338,6 +338,7 @@ export interface IssuesListFilters {
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
includeLiveDescendantSummary?: boolean;
}
export interface IssuesListProps {

View File

@ -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;

View File

@ -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,
});
});
});

View File

@ -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,

View File

@ -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<Map<string, number>> {
const uniqueIssueIds = [...new Set(issueIds)];
const map = new Map<string, number>();
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<unknown>);
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> = {}): 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<string, number>()),
]);
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<string, IssueRelationIssueSummary[]>()),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
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) }
: {}),

View File

@ -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",

View File

@ -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");
}

View File

@ -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<void>) {
let result: void | Promise<void> | 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<string>(),
subtreeLiveCounts: new Map<string, number>(),
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(
<BlockedInboxView
{...blockedViewProps}
subtreeLiveCounts={new Map([["blocked-parent", 1]])}
/>,
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"));

View File

@ -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<string>;
subtreeLiveCounts: ReadonlyMap<string, number>;
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<string, string>;
userLabelById?: ReadonlyMap<string, string>;
liveIssueIds: ReadonlySet<string>;
subtreeLiveCounts: ReadonlyMap<string, number>;
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 = (
<span className="flex shrink-0 items-center gap-3 text-xs">
@ -345,13 +361,14 @@ function BlockedInboxRow({
desktopMetaLeading={
<BlockedRowDesktopMeta
row={row}
blockerAttention={blockerAttention}
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
/>
}
mobileLeading={
<span className="flex shrink-0 items-center gap-1.5 pt-px">
<StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} />
<StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
</span>
}
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 (
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} /> : null}
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={blockerAttention} /> : null}
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
</span>
);

View File

@ -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(
<InboxIssueMetaLeading
issue={makeIssue({ id: "parent", identifier: "PAP-1", status: "blocked" })}
isLive={false}
subtreeLiveCount={2}
showSubtreeLiveChip={false}
/>,
);
expect(text).not.toContain("live below");
});
it("renders no live treatment when the issue and its subtree are idle", () => {
const text = renderLeading(
<InboxIssueMetaLeading

View File

@ -137,6 +137,7 @@ export function InboxIssueMetaLeading({
issue,
isLive,
subtreeLiveCount = 0,
showSubtreeLiveChip = true,
showStatus = true,
showIdentifier = true,
statusSlot,
@ -145,6 +146,7 @@ export function InboxIssueMetaLeading({
issue: Issue;
isLive: boolean;
subtreeLiveCount?: number;
showSubtreeLiveChip?: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
statusSlot?: ReactNode;
@ -193,7 +195,7 @@ export function InboxIssueMetaLeading({
</span>
</span>
)}
{!isLive && subtreeLiveCount > 0 && (
{showSubtreeLiveChip && !isLive && subtreeLiveCount > 0 && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 sm:gap-1.5 sm:px-2",

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { Issue, IssueBlockerAttention } from "@paperclipai/shared";
import {
resolveInboxIssueBlockerAttention,
resolveIssueLiveDescendantCount,
} from "./inbox-live-descendants";
function makeIssue(overrides: Partial<Issue>): Issue {
return {
id: "issue-1",
status: "blocked",
blockerAttention: null,
liveDescendantCount: 0,
...overrides,
} as unknown as Issue;
}
function makeBlockerAttention(
overrides: Partial<IssueBlockerAttention> = {},
): 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();
});
});

View File

@ -0,0 +1,55 @@
import type { Issue, IssueBlockerAttention } from "@paperclipai/shared";
type InboxLiveDescendantIssue = Pick<Issue, "status" | "blockerAttention" | "liveDescendantCount">;
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<IssueBlockerAttention>;
return typeof attention.state === "string" ? attention as IssueBlockerAttention : null;
}
export function resolveIssueLiveDescendantCount(
issue: Pick<Issue, "liveDescendantCount">,
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,
};
}

View File

@ -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<void>) {
let result: void | Promise<void> = 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(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
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(() => {

View File

@ -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 = (
<StatusIcon status={issue.status} blockerAttention={blockerAttention} />
);
return (
<IssueRow
key={`issue:${issue.id}`}
@ -2407,10 +2433,12 @@ export function Inbox() {
{depth > 0 ? <span className="hidden w-4 shrink-0 sm:block" /> : null}
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds.has(issue.id)}
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
isLive={isLive}
subtreeLiveCount={liveDescendantCount}
showSubtreeLiveChip={showSubtreeLiveChip}
showStatus={showStatus}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
statusSlot={rowStatusIcon}
/>
</>
}
@ -2433,7 +2461,9 @@ export function Inbox() {
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : undefined
) : (
<StatusIcon status={issue.status} blockerAttention={blockerAttention} size="lg" />
)
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)}

View File

@ -187,6 +187,7 @@ function BlockedTabSurface({ search = "" }: { search?: string }) {
searchQuery={search}
agentNameById={new Map()}
issueLinkState={null}
subtreeLiveCounts={new Map()}
/>
</div>
</div>
@ -259,6 +260,7 @@ function BlockedTabEmptyState() {
searchQuery=""
agentNameById={new Map()}
issueLinkState={null}
subtreeLiveCounts={new Map()}
/>
</div>
);