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:
parent
b4815bf964
commit
2f94a66ba1
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,7 @@ export interface IssuesListFilters {
|
|||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeLiveDescendantSummary?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuesListProps {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue