perf(ui): speed up task and inbox cold opens (#17756)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task lists and inboxes are the main entry points to that work.
> - Cold opens fetch overlapping task lists, task descriptions, and run
details before useful rows settle.
> - Independent responses can insert rows above content that a person
has started to read.
> - This pull request loads a small task index first and adds details
only for visible rows.
> - It keeps row order and geometry stable while those details arrive.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Cold opens of Tasks and Inbox, including the shared navigation badge.

**Subsystem affected**

Server list reads, shared response contracts, and the React UI.

**Current behavior**

The badge fetches full task bodies. Mine fetches several overlapping
lists. Historical diagnostics and row metadata add work before the first
useful list. A fast failed-run response can appear before task rows,
then move down when tasks arrive.

**Proposed behavior**

Load the selected inbox source and its required context together. Use an
index without task descriptions or recovery diagnostics. Fetch
diagnostic details for up to 50 visible rows. Keep skeletons, recovery
indicators, external status, and live metadata in fixed space.

**Reason and benefit**

Reduce work on a cold open and prevent loading-induced row movement. On
the same 100 local fixture tasks, the compact response was 262,441
decoded bytes and the index was 110,909 bytes. This comparison excludes
compression and is not a page latency benchmark.

**Breaking changes**

Existing full and compact responses remain available. New index/row
projections are opt-in and marked as partial data. Mine keeps its
existing most-recent-100 display limit, now selected on the server.
Existing filters and group preferences remain in the client. No database
migration.

**Additional context**

Refs: #17593. Related: #13095, #17428, #13803. This change addresses
collection overfetch and layout stability. It does not replace the
blocked-inbox query work or the database activity-index work.

## What Changed

- Add authorized index and row projections, with bounded task and parent
ID batches.
- Replace badge list downloads with unread IDs and aggregate counts.
Preserve actor privacy, dismissals, and local archive behavior.
- Load only the selected inbox source, plus bounded parent and
failed-run context. Return only relevant failures from the bounded run
window.
- Batch hierarchy reads, including at most one authorized child per
collapsed parent in one request. Collapsed markers do not consume the
expanded row budget, and partial descendant counts are not shown as
totals. Enrich visible rows without replacing membership, ordering,
titles, or read state.
- Reserve row geometry on desktop and mobile. Reveal the initial inbox
only after its sources settle. Preserve legacy task-list reads.
- Start collection module downloads during access checks. Keep partial
rows out of the task-detail cache.
- Keep ETags and concurrent request sharing for the new projections, but
skip the short server response TTL so archive undo sees fresh state.

## Verification

- On final head `ef6afb2a56`, the full CI workflow passed, including
build, typecheck, server/workspace/runner suites, and all eight browser
shards. A server shard reported **1,489 passing tests** before runner
shutdown canceled the job; its single infrastructure retry passed. Both
Greptile and Paperclip Review are **5/5**, with zero unresolved review
threads.
- The optional external **Entire Gates** check remains `unassessed`
because it has no active assessed Change for this comparison. Entire is
not set up in this checkout. The repository ruleset requires `ci /
verify` and `ci / e2e`; both passed. No gate configuration was changed.

- Passed workspace typecheck after the final API batching change. The
full production build also passed on the final commit; the exact-retry
E2E run built the shipped UI.
- Passed token gates and map validation. The final batch-preview pass
has 91 passing route/hierarchy/component tests, including privacy,
request bounds, 30 collapsed parents in one read, and a 120-child parent
alongside a sibling.
- Passed the 166-test focused UI/inbox/badge run, 47 additional UI
regression tests, and 34 issue-list route tests. The latest regressions
cover low-trust agent badge IDs, cold company selection, 30
description-only Mine matches with archive actions, retrying optional
row details, and collapsed/expanded siblings.
- The full local `pnpm test:run` spanned the rebase and reported
failures in five suites before it was stopped; it is not claimed as a
complete pass. A fresh serial rerun of all five suites passed
**452/452** tests (tool gateway, native resume, project repositories,
workspace recovery, and runner selection). Full current-commit coverage
is checked by CI.
- Real browser: delayed index and row-detail responses on Tasks and
Mine. The first placeholder and first row had the same origin and
44-pixel height. Twelve rows on each page had zero position, height, or
title-width change after recovery details arrived.
- Real browser: reviewed long titles and recovery status at a 390 by 844
viewport. Task text stays on one line and recovery uses a compact
indicator.
- Three fresh document loads per page on final commit `260893bf92`, with
HTTP cache retained on a warm local development server: Tasks
1.031–1.190 seconds; Mine 0.999–1.193 seconds. Browser-control overhead
is included. These values must not be compared directly with staging
latency or the earlier local samples taken during a full test run.
- The browser archive test exposed stale results after Undo. The new
projections now bypass the server TTL; immediate-mutation regression
tests pass. The final browser retry passed: Archive followed by Undo
restored exactly one row and the badge count.

- CI exposed a hook-order crash when company selection resolved after
the first inbox render. The hook now runs before the empty-company
return. All seven affected exact-retry and legacy-continuation browser
scenarios pass locally after the fix.
- Real browser review regressions: 97 description-only source matches
retained Archive actions; deliberately failed optional row reads
displayed a retry notice while all 12 observed row positions and heights
stayed unchanged. Retry restored the recovery indicator. Network fault
injection was removed.

- The final production build and workspace typecheck passed. Final
focused verification passed 153 UI/helper tests and 26 heartbeat privacy
route tests. Final review regressions cover newer diagnostics arriving
during the index read, reloading diagnostics when the same task ID
changes version, optional source failure/retry without hiding available
rows, and required external-status filter failure. The latest-failures
route test verifies bounded selection, agent filtering, success
suppressing an older failure, and private-run redaction.
- Real browser: deliberately failed the run source, then retried it.
Twelve available task rows kept the same position and 44-pixel height
before, during, and after retry. No loading skeleton replaced them.
Fault injection was removed.
- Hierarchy review decision: the cold-open barrier stays because
inserting ancestors or expanded children after revealing index rows
would cause layout shifts. Existing hierarchy context now survives
subsequent requests, with a regression proving a new ancestor read does
not blank the list. The bounded recursive tree read can still add
latency for unusually deep expanded trees.

- Tasks now handles a failed external-status filter with an explicit
error and Retry, suppressing a false empty state. All 52 Tasks component
tests pass, including failure-to-success retry. One existing large-tree
test timed out while workspace typechecking ran concurrently; the whole
component suite passed when rerun without that competing work. Workspace
typecheck, the full production build, and token gates passed for this
fix.

## Risks

- Saved filters and grouping still operate on the existing bounded
collection. This is not a new global server pagination model.
- Fixed row slots reduce available title width. Long titles remain
accessible through task navigation.
- Recovery and related diagnostics arrive after core rows. Their
responses cannot replace ordering fields, and stale versions are
rejected.
- Index/row requests now revalidate against current data instead of a
short server TTL. They still share concurrent reads and use ETags.
- This has not been deployed by this pull request. Staging latency must
be measured after deployment under comparable conditions.

## Model Used

OpenAI Codex, GPT-6 family. The exact runtime model ID and
context-window size are not exposed in this session. Used code editing,
terminal execution, and real-browser diagnostics.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-26 14:29:20 -05:00
1 parent dfedd73f56
commit 5061ab7b35
41 files changed
+1214 -551

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# Task list and inbox cold opens
## Outcome and ownership
Cold-open `/issues` and `/inbox/*` should show useful, correctly ordered rows
without inserting earlier rows or changing row geometry as supporting data arrives.
This work owns the collection read contracts, their UI consumers, and navigation
badge reads. Keep issue authorization, mutation semantics, archive/undo, and
existing grouping/filter preferences with their current owners. The separate
staging performance effort (PR #17745) owns runner event-loop stalls.
Branch: `codex/list-cold-open`, rebased onto `063a498b54`.
Endpoint: tested, reviewable PR with checks and review resolved; local test drive.
No staging infrastructure changes are needed for this implementation.
## Evidence and acceptance
Baseline on staging, authenticated browser with HTTP cache retained and fresh
application state: task rows ~4.5s; Mine task rows ~6.3s. Mine downloaded four
overlapping issue responses (500, 167, 52, 52 rows; 2,388,103 decoded bytes).
A failed-run row appeared at y=183 before tasks arrived, then moved to y=415.
These are individual observations, not percentile benchmarks.
| Journey | Required result | Evidence |
| --- | --- | --- |
| Cold Tasks / Mine | Bounded initial data; final row order before reveal; metadata fills stable slots | Request sizes, delayed-response browser trace, row geometry |
| Other inbox tabs and saved filters/grouping/nesting | Correct membership/order before paging, no false empty state | Focused contract/component tests plus local browser |
| More results and detail/back | No lost or duplicated rows; reading position preserved | Browser walkthrough and paging regressions |
| Archive/undo/read and background activity | Existing optimistic behavior survives; observed rows stay put | Existing regression suites and browser |
| Slow/failed metadata or list read | Stable layout; useful retry/error; no misleading partial inbox | Deferred request component tests and browser |
| Company/account boundaries | No cross-company or private-row leaks in lists or badges | Authorization tests |
## Implementation sequence
1. Remove full-list badge reads; preserve personal unread/archive/dismissal semantics.
2. Add a lean ordering index using existing pagination/filter contracts, then enrich
only visible rows (at most 50). Batch hierarchy reads rather than fetching full
task bodies per parent. Mine selects its most recent 100 rows server-side;
existing client grouping/filtering remains in place. This is not a new global
server-side pagination model for every saved view.
3. Commit inbox source data together rather than revealing unrelated responses in arrival order.
4. Match skeleton and row geometry. Enrichment cannot replace index membership,
sorting fields, or row geometry; retain the existing inbox order pin.
5. Preload the requested collection module during access/company checks and stop
downloading task detail code eagerly on collection opens.
6. Verify in the existing isolated local app, run required checks, prepare PR and resolve review.
Use targeted tests while iterating; full repository verification before PR-ready handoff.
Do not infer deployed speedup from local measurements or combine the separate server fix's savings.
## State
Implemented the bounded index, visible-row enrichment, compact badge reads, and
initial snapshot barrier. Legacy task lists retain their existing compact data.
Index and row requests retain ETags and in-flight coalescing but skip the short
server response TTL, so an archive undo immediately reads the changed state.
Local browser proof uses 160 seeded tasks, nested rows, long titles, unread
activity, and a saved recovery action. On desktop, the first Tasks placeholder
and task both start at y=163 with height 44. The equivalent inbox values are
y=175 and height 44. Delayed row enrichment produced zero position, height, or
title-width change across 12 measured rows on each page. Narrow-screen review
at 390 by 844 confirmed truncation and a compact recovery indicator.
Three fresh document loads with retained HTTP cache and a warm local Vite server
showed Tasks in 2.054–2.170s and Mine in 2.115–2.268s after the archive/undo fix. These include browser-control
overhead while the test suite ran on the same computer. They are not comparable
to staging latency. For the same 100 local tasks, the old compact response was
262,441 decoded bytes and the index was 110,909 bytes, with no descriptions.
Workspace typecheck, production build, token gates, map validation, and focused
UI/server regressions passed. The initial full local run spanned a rebase and was
stopped after reporting failures; a clean serial rerun of all five affected
suites passed 452 tests. Fresh current-commit CI is the full-suite acceptance gate.
Review fixes preserve description-only inbox matches through a scoped, on-demand
index search and keep unread task IDs board-only. Collapsed parents use a batched
one-child preview instead of spending the expanded row budget or making a request
per parent. Optional-detail failures show a retry notice outside document flow;
12 measured rows remained unchanged and retry restored the recovery indicator.
All seven exact-run retry/legacy continuation browser scenarios passed after
fixing the company-loading hook order. Archive/Undo, read/back, nesting, remote
search and rendering all 164 fixture tasks without duplicates also passed.
PR: https://github.com/paperclipai/paperclip/pull/17756.
Browser evidence is retained in the chat's `list-cold-open-test-drive` artifact
directory. Existing task-detail PR #17593 is already merged and verified on
staging; its paused hourly monitor is not part of this change.
The final review pass accepts newer diagnostic snapshots while retaining index
ordering, and refreshes visible diagnostics when a task version changes. Optional
inbox source failures keep available rows and expose retry outside document flow;
required external-status filter failures show an error rather than an endless
skeleton. A browser-injected failed run source retained 12 rows at the same
positions and heights before, during, and after retry. The initial hierarchy
barrier remains intentional to avoid inserting ancestors or children above rows
already being read; subsequent hierarchy requests retain the previous context.
Server route coverage now verifies latest-failure filtering, bounded windows,
agent filters, and private-run redaction. The unused client badge implementation
and its obsolete tests were removed; the production server badge tests remain.
+10
View File
@@ -18,9 +18,19 @@ Query parameters:
| `status` | Filter by status (comma-separated: `todo,in_progress`) |
| `assigneeAgentId` | Filter by assigned agent |
| `projectId` | Filter by project |
| `view` | `index`: collection rows without bodies or diagnostics; `rows`: rows with diagnostic summaries but no bodies; `compact`: existing board row contract |
| `ids` | One to 100 comma-separated task UUIDs; requires `view=index` or `view=rows` without `attention` |
| `parentIds` | One to 100 comma-separated parent UUIDs for batched child context; same projection restriction as `ids` |
| `oneChildPerParent` | With `view=index` and `parentIds`, return at most one readable child per parent for collapsed-tree affordances |
Results sorted by priority.
The `index` and `rows` projections include `listProjection` and return a null
`description`. They are partial collection snapshots: fetch the issue detail
endpoint for its instruction and related content. Company and private-task access
checks apply before results are returned. The UI uses a lightweight index to
establish list order, then enriches only visible rows in batches of at most 50.
## Get Issue
```
+7
View File
@@ -23,6 +23,13 @@ visibility exception. Company/private-task access checks still apply. Collection
preferences and hierarchy presentation are browser state, not additional authority.
Source-reviewed behavior; live agent access verification remains separate.
Collection projections `view=index` and `view=rows` use the same issue-list
company/private-task checks. Optional `ids` and `parentIds` batches accept at most
100 UUIDs and cannot be combined with blocked attention. They grant no new access.
`listProjection` marks omitted task bodies so clients do not reuse them as detail.
The sidebar count endpoint returns only readable unread Mine task IDs; failed-run
counts apply the run read decision. No new agent execution or mutation path is added.
Review: `baseline`.
API: `present` — representative operation IDs:
+9
View File
@@ -67,6 +67,15 @@ participation, while the cleanup remains in activity history. Record an explicit
user recovery decision on a separate task and verify it still counts. This
browser journey remains manual.
Cold-open verification: load Mine with the task-index response delayed while
approvals/runs resolve. Only the stable list placeholder should appear until the
initial sources settle; a failed-run row must not jump when tasks arrive. Release
visible-row details separately and compare row/title bounds. Mine reads its own
100-row index, with parent/run context by ID; sidebar badges use the count endpoint.
The [Inbox component checks](../../ui/src/pages/Inbox.test.tsx) cover the source
barrier and selected-source query; [badge tests](../../server/src/__tests__/inbox-dismissals.test.ts)
cover unread counts. Browser timing and layout proof remain separate from these tests.
### `decisions`
Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual.
+7
View File
@@ -29,6 +29,13 @@ separate. It does not cover other property operations or agent authorization.
## How to get to it (user POV)
Cold collection loads use a lightweight index and batched hierarchy context,
then fetch diagnostic details for visible rows. Delay the index, hierarchy, and
visible-row details independently when verifying `tasks/organize`: row heights,
title bounds, and the first-row origin should remain stable. Test nested expansion,
scrolling to more rows, and detail/back navigation; a collection projection must
never suppress the full task instruction fetch.
### `new-task`
Use New task from the global launcher, task list, or project task view.
+9
View File
@@ -8,3 +8,12 @@ export function isHeartbeatRunVisibleInMine(
// Preserve unattributed historical runs for the single-user local board.
return currentUserId === "local-board";
}
/** The inbox lists failures only when the agent's latest observed run failed. */
export function latestFailedRunsByAgent<T extends { agentId: string; status: string; createdAt: Date | string }>(runs: readonly T[]): T[] {
const latest = new Map<string, T>();
for (const run of [...runs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())) {
if (!latest.has(run.agentId)) latest.set(run.agentId, run);
}
return [...latest.values()].filter((run) => run.status === "failed" || run.status === "timed_out");
}
+1 -1
View File
@@ -2795,7 +2795,7 @@ export { restoreAgentInstructionSchema } from "./validators/agent.js";
export type { AgentInstructionCandidate } from "./types/agent.js";
export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js";
export { isHeartbeatRunVisibleInMine } from "./heartbeat-inbox.js";
export { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent } from "./heartbeat-inbox.js";
export * from "./browser-use.js";
export * from "./types/skill-source.js";
+3
View File
@@ -843,6 +843,8 @@ export type IssueSummary = Pick<
};
export interface Issue {
/** Collection projections omit task bodies and cannot seed the detail cache. */
listProjection?: "index" | "rows";
statusVersion?: number;
/** True only while the title is the provisional slice of the initial prompt. */
titleNeedsGeneration?: boolean;
@@ -958,6 +960,7 @@ export interface Issue {
export type CompactIssue = Pick<
Issue,
| "listProjection"
| "externalConversationState"
| "id"
| "companyId"
@@ -3,4 +3,6 @@ export interface SidebarBadges {
approvals: number;
failedRuns: number;
joinRequests: number;
/** Visible unread Mine tasks, retained so optimistic archive can adjust the badge. */
unreadIssueIds?: string[];
}
@@ -328,6 +328,37 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
expect(maintenance.body.resultJson.summary).toBe("Maintenance complete");
});
it("filters latest failures within the requested run window and preserves private-run redaction", async () => {
const f = await seedFixture();
await db.update(heartbeatRuns).set({ status: "failed", createdAt: new Date("2026-10-26T10:00:00Z") })
.where(eq(heartbeatRuns.id, f.privateRunId));
await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T09:00:00Z") })
.where(eq(heartbeatRuns.id, f.maintenanceRunId));
const timeoutId = randomUUID();
await db.insert(heartbeatRuns).values({
id: timeoutId, companyId: f.companyId, agentId: f.otherAgentId, scopeKind: "company",
invocationSource: "timer", status: "timed_out", createdAt: new Date("2026-10-26T11:00:00Z"),
});
const app = createApp(f.companyId, f.otherAgentId);
const path = `/api/companies/${f.companyId}/heartbeat-runs?summary=true&latestFailuresOnly=true`;
const response = await request(app).get(path).expect(200);
expect(response.body.map((run: { id: string }) => run.id)).toEqual([timeoutId, f.privateRunId]);
const privateStub = response.body[1];
expect(privateStub).toMatchObject({ redacted: true, status: "failed" });
expect(privateStub).not.toHaveProperty("issueId");
expect(privateStub).not.toHaveProperty("contextSnapshot");
expect(JSON.stringify(response.body)).not.toContain("Confidential email triage");
const bounded = await request(app).get(`${path}&limit=1`).expect(200);
expect(bounded.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]);
const agentOnly = await request(app).get(`${path}&agentId=${f.ownerAgentId}`).expect(200);
expect(agentOnly.body.map((run: { id: string }) => run.id)).toEqual([f.privateRunId]);
// A newer success suppresses an older failure rather than selecting the latest failure alone.
await db.update(heartbeatRuns).set({ createdAt: new Date("2026-10-26T12:00:00Z") })
.where(eq(heartbeatRuns.id, f.maintenanceRunId));
const recovered = await request(app).get(path).expect(200);
expect(recovered.body.map((run: { id: string }) => run.id)).toEqual([timeoutId]);
});
it("returns full private-run content to the owner and an explicitly granted agent", async () => {
const fixture = await seedFixture();
@@ -11,6 +11,9 @@ import {
heartbeatRuns,
inboxDismissals,
invites,
issues,
issueComments,
issueReadStates,
joinRequests,
} from "@paperclipai/db";
import {
@@ -22,6 +25,7 @@ import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js";
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
import { sidebarBadgeRoutes } from "../routes/sidebar-badges.js";
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -46,6 +50,9 @@ describeEmbeddedPostgres("inbox dismissals", () => {
}, 20_000);
afterEach(async () => {
await db.delete(issueReadStates);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(inboxDismissals);
await db.delete(joinRequests);
await db.delete(invites);
@@ -100,6 +107,49 @@ describeEmbeddedPostgres("inbox dismissals", () => {
expect(response.body.inbox).toBe(expected);
});
it("counts only unread Mine tasks without returning their bodies", async () => {
const companyId = randomUUID(), issueId = randomUUID(), otherId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Badge fixture", issuePrefix: "BADGE" });
const createdAt = new Date("2026-03-11T01:00:00Z");
const replyAt = new Date("2026-03-11T02:00:00Z");
await db.insert(issues).values([
{ id: issueId, companyId, title: "Mine", description: "Large body".repeat(10000), createdByUserId: "local-board", createdAt },
{ id: otherId, companyId, title: "Not mine", createdByUserId: "someone-else", createdAt },
]);
await db.insert(issueComments).values([issueId, otherId].map((id) => ({ companyId, issueId: id, authorType: "user" as const, authorUserId: "someone-else", body: "Reply", createdAt: replyAt })));
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "local-board", companyIds: [companyId], isInstanceAdmin: true };
next();
});
app.use("/api", sidebarBadgeRoutes(db));
app.use(errorHandler);
const response = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
expect(response.body).toMatchObject({ inbox: 1, unreadIssueIds: [issueId] });
expect(JSON.stringify(response.body).length).toBeLessThan(300);
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId, companyId, name: "Restricted reviewer", role: "engineer",
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, rootIssueId: otherId, projectIds: [] } },
},
});
const agentApp = express();
agentApp.use((req, _res, next) => {
req.actor = { type: "agent", source: "agent_jwt", agentId, companyId, onBehalfOfUserId: "local-board",
onBehalfOfMemberships: [{ companyId, membershipRole: "member", status: "active" }] };
next();
});
agentApp.use("/api", sidebarBadgeRoutes(db));
agentApp.use(errorHandler);
const agentResponse = await request(agentApp).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
expect(agentResponse.body).toMatchObject({ inbox: 0, unreadIssueIds: [] });
await db.insert(issueReadStates).values({ companyId, issueId, userId: "local-board", lastReadAt: replyAt });
const read = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200);
expect(read.body).toMatchObject({ inbox: 0, unreadIssueIds: [] });
});
it("upserts a single dismissal record per user and inbox item key", async () => {
const companyId = randomUUID();
const userId = "board-user";
@@ -97,6 +97,80 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
});
}
it.each(["index", "rows"])("revalidates %s rows immediately after a mutation", async (view) => {
const companyId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Fresh list", issuePrefix: uniqueIssuePrefix() });
await seedCloudTenantMember(companyId);
await db.insert(issues).values({ id: issueId, companyId, title: "Before" });
const app = createApp(companyId);
const before = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId }).expect(200);
await db.update(issues).set({ title: "After" }).where(eq(issues.id, issueId));
const after = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: issueId })
.set("If-None-Match", before.headers.etag).expect(200);
expect(after.body[0].title).toBe("After");
expect(after.headers.etag).not.toBe(before.headers.etag);
expect(after.headers["x-paperclip-request-cache"]).toBe("miss");
});
it("keeps index and row batches scoped to readable tasks and validates their bounds", async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const companyId = randomUUID(), foreignCompanyId = randomUUID();
const parentId = randomUUID(), childId = randomUUID(), privateId = randomUUID(), foreignId = randomUUID();
await db.insert(companies).values([
{ id: companyId, name: "Lists", issuePrefix: uniqueIssuePrefix() },
{ id: foreignCompanyId, name: "Other", issuePrefix: uniqueIssuePrefix() },
]);
await seedCloudTenantMember(companyId);
await db.insert(issues).values([
{ id: parentId, companyId, title: "Public parent" },
{ id: childId, companyId, parentId, title: "Public child" },
{ id: privateId, companyId, parentId, title: "Private child", visibility: "private", privacyRootIssueId: privateId, createdByUserId: "someone-else" },
{ id: foreignId, companyId: foreignCompanyId, title: "Foreign task" },
]);
const app = createApp(companyId);
for (const view of ["index", "rows"]) {
const selected = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, ids: [parentId, privateId, foreignId].join(",") });
expect(selected.status, JSON.stringify(selected.body)).toBe(200);
expect(selected.body.map((row: { id: string }) => row.id)).toEqual([parentId]);
const children = await request(app).get(`/api/companies/${companyId}/issues`).query({ view, parentIds: parentId });
expect(children.status).toBe(200);
expect(children.body.map((row: { id: string }) => row.id)).toEqual([childId]);
}
const preview = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", parentIds: parentId, oneChildPerParent: true }).expect(200);
expect(preview.body.map((row: { id: string }) => row.id)).toEqual([childId]);
for (const query of [
{ view: "index", ids: "invalid" },
{ view: "index", parentIds: Array.from({ length: 101 }, () => randomUUID()).join(",") },
{ view: "index", ids: parentId, attention: "blocked" },
{ view: "compact", ids: parentId },
{ view: "index", oneChildPerParent: true },
{ view: "rows", parentIds: parentId, oneChildPerParent: true },
{ view: "index", parentIds: parentId, oneChildPerParent: "invalid" },
]) {
expect((await request(app).get(`/api/companies/${companyId}/issues`).query(query)).status).toBe(400);
}
} finally { vi.unstubAllEnvs(); }
});
it("returns one child per parent without letting a large collapsed tree fill the page", async () => {
const companyId = randomUUID(), firstParentId = randomUUID(), secondParentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Collapsed trees", issuePrefix: uniqueIssuePrefix() });
await seedCloudTenantMember(companyId);
await db.insert(issues).values([
{ id: firstParentId, companyId, title: "First parent" },
{ id: secondParentId, companyId, title: "Second parent" },
]);
await db.insert(issues).values([
...Array.from({ length: 120 }, (_, index) => ({ companyId, parentId: firstParentId, title: `Child ${index}` })),
{ companyId, parentId: secondParentId, title: "Other child" },
]);
const response = await request(createApp(companyId)).get(`/api/companies/${companyId}/issues`)
.query({ view: "index", parentIds: [firstParentId, secondParentId].join(","), oneChildPerParent: true, limit: 2 }).expect(200);
expect(response.body).toHaveLength(2);
expect(new Set(response.body.map((row: { parentId: string }) => row.parentId))).toEqual(new Set([firstParentId, secondParentId]));
});
it("returns only unassigned issues for assigneeAgentId=null", async () => {
const companyId = randomUUID();
const assigneeAgentId = randomUUID();
@@ -240,6 +314,15 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
assigneeAgentId: ownerAgentId,
},
});
const index = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "index", limit: "20" });
expect(index.status).toBe(200);
expect(index.body).toHaveLength(1);
expect(index.body[0]).toMatchObject({ id: issueId, listProjection: "index", description: null, activeRecoveryAction: null, successfulRunHandoff: null });
expect(index.headers.etag).not.toBe(res.headers.etag);
const detailRows = await request(app).get(`/api/companies/${companyId}/issues`).query({ view: "rows", ids: issueId });
expect(detailRows.status).toBe(200);
expect(detailRows.body[0]).toMatchObject({ id: issueId, listProjection: "rows", description: null, activeRecoveryAction: { id: recoveryAction.id } });
expect(res.body[0]).not.toHaveProperty("workProducts");
expect(res.body[0]).not.toHaveProperty("project");
expect(res.body[0]).not.toHaveProperty("goal");
+3 -1
View File
@@ -1,3 +1,4 @@
import { latestFailedRunsByAgent } from "@paperclipai/shared";
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
@@ -6910,7 +6911,8 @@ export function agentRoutes(
? Math.max(1, Math.min(HEARTBEAT_RUN_LIST_MAX_LIMIT, parseInt(limitParam, 10) || HEARTBEAT_RUN_LIST_DEFAULT_LIMIT))
: HEARTBEAT_RUN_LIST_DEFAULT_LIMIT;
const summary = req.query.summary === "true" || req.query.summary === "1";
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
const recentRuns = await heartbeat.list(companyId, agentId, limit, { summary });
const runs = req.query.latestFailuresOnly === "true" ? latestFailedRunsByAgent(recentRuns) : recentRuns;
const readDecisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs);
res.json(await runRedactions.redactForRuns(companyId, runs.map((run, index) =>
readDecisions[index] ? run : redactHeartbeatRunListRow(run)),
+36 -7
View File
@@ -8354,7 +8354,26 @@ export function issueRoutes(
const sortField = req.query.sortField as string | undefined;
const sortDir = req.query.sortDir as string | undefined;
const view = req.query.view as string | undefined;
const compactView = view === "compact";
const compactView = view === "compact" || view === "index" || view === "rows";
const issueIds = typeof req.query.ids === "string" ? [...new Set(req.query.ids.split(","))] : undefined;
const parentIds = typeof req.query.parentIds === "string" ? [...new Set(req.query.parentIds.split(","))] : undefined;
const oneChildPerParent = parseOptionalBooleanQuery(req.query.oneChildPerParent);
if (oneChildPerParent === null || (oneChildPerParent && (view !== "index" || !parentIds))) {
res.status(400).json({ error: "oneChildPerParent must be a boolean and requires view=index with parentIds" });
return;
}
if (req.query.ids !== undefined && (!issueIds?.length || issueIds.length > 100 || issueIds.some((id) => !z.string().uuid().safeParse(id).success))) {
res.status(400).json({ error: "ids must contain one to 100 comma-separated UUIDs" });
return;
}
if (req.query.parentIds !== undefined && (!parentIds?.length || parentIds.length > 100 || parentIds.some((id) => !z.string().uuid().safeParse(id).success))) {
res.status(400).json({ error: "parentIds must contain one to 100 comma-separated UUIDs" });
return;
}
if ((issueIds || parentIds) && (attention || (view !== "index" && view !== "rows"))) {
res.status(400).json({ error: "ids and parentIds require view=index or view=rows without attention" });
return;
}
const hasPlanDocument = parseOptionalBooleanQuery(
req.query.hasPlanDocument,
);
@@ -8421,8 +8440,8 @@ export function issueRoutes(
.json({ error: "attention must be 'blocked' when provided" });
return;
}
if (view !== undefined && view !== "compact") {
res.status(400).json({ error: "view must be 'compact' when provided" });
if (view !== undefined && !["compact", "index", "rows"].includes(view)) {
res.status(400).json({ error: "view must be compact, index, or rows when provided" });
return;
}
if (
@@ -8509,6 +8528,10 @@ export function issueRoutes(
const offset = parsedOffset ?? 0;
const listFilters: IssueFilters = {
issueIds,
parentIds,
oneChildPerParent,
listProjection: view === "index" || view === "rows" ? view : undefined,
readCondition: await issueReadSqlCondition(db, req.actor),
attention: attention === "blocked" ? "blocked" : undefined,
status: req.query.status as string | string[] | undefined,
@@ -8564,14 +8587,16 @@ export function issueRoutes(
companyId,
normalizedQuery: {
...requestKeyFilters,
view: compactView ? "compact" : undefined,
view,
},
});
const coordinated = await coordinateIssueListGet({
req,
companyId,
requestKey,
allowTtlCache: compactView,
// The new collection projections must reflect read/archive/undo mutations
// immediately. Keep in-flight coalescing and ETags, without a stale TTL body.
allowTtlCache: view === "compact",
diagnostics: opts.issueListDiagnostics,
compute: async () => {
const rawResult = await svc.list(companyId, listFilters);
@@ -8579,6 +8604,10 @@ export function issueRoutes(
? rawResult
: await filterIssuesForActor(req, rawResult);
const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows);
if (view === "index") {
const body = result.map((issue) => ({ ...toCompactIssue(issue), listProjection: "index" as const }));
return { kind: "compact", body, etag: compactIssueListEtag(body), cacheControl: "private, must-revalidate" };
}
const issueIds = result.map((issue) => issue.id);
const interruptions = await taskInterruptionPresentations(db, companyId, result);
if (compactView) {
@@ -8603,12 +8632,12 @@ export function issueRoutes(
}),
);
const compactResult = result.map((issue) =>
toCompactIssue({
({ ...toCompactIssue({
...issue,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
interruption: interruptions.get(issue.id) ?? null,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
}),
}), ...(view === "rows" ? { listProjection: "rows" as const } : {}) }),
);
return {
kind: "compact",
+8 -2
View File
@@ -4269,10 +4269,15 @@ registry.registerPath({
tags: ["issues"],
summary: "List issues in a company",
description:
"Use `view=compact` for the board issue-list row contract. The default response remains the broad compatibility contract.",
"Use view=index for collection membership/order without task bodies or diagnostic enrichment; view=rows enriches visible rows without task bodies. These responses include listProjection and cannot stand in for a full issue. view=compact retains the board row compatibility contract. The default response remains the broad compatibility contract. All projections enforce company and task access.",
request: {
params: z.object({ companyId: z.string() }),
query: z.object({ view: z.enum(["compact"]).optional() }).passthrough(),
query: z.object({
view: z.enum(["compact", "index", "rows"]).optional(),
ids: z.string().optional().describe("One to 100 comma-separated task UUIDs; requires index or rows without attention."),
parentIds: z.string().optional().describe("One to 100 comma-separated parent UUIDs; requires index or rows without attention."),
oneChildPerParent: z.boolean().optional().describe("Return at most one readable child per parent; requires view=index and parentIds."),
}).passthrough(),
},
responses: {
200: r.ok(),
@@ -7477,6 +7482,7 @@ registry.registerPath({
agentId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(1000).default(200),
summary: z.enum(["true", "false", "1", "0"]).optional(),
latestFailuresOnly: z.enum(["true", "false"]).optional().describe("Return failures only when the agent's latest run within the bounded result failed or timed out. Applied before read redaction."),
}),
},
responses: { 200: r.ok(), 401: r.unauthorized },
+13 -1
View File
@@ -6,6 +6,8 @@ import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js";
import { assertCompanyAccess } from "./authz.js";
import { issueService } from "../services/issues.js";
import { approvalReadSqlCondition, issueReadSqlCondition } from "../services/authorization.js";
function buildDismissedAtByKey(
dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>,
@@ -27,6 +29,7 @@ export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
const access = accessService(db);
const issues = issueService(db);
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
const companyId = req.params.companyId as string;
@@ -77,15 +80,24 @@ export function sidebarBadgeRoutes(db: Db) {
: new Map<string, number>();
// Company health alerts belong in All, not the personal inbox badge.
// These IDs reconcile optimistic archives in the board UI. An agent's
// responsible user does not grant it access to that user's task collection.
const currentUserId = req.actor.type === "board" ? req.actor.userId ?? null : null;
const unreadIssueIds = currentUserId
? await issues.unreadInboxIssueIds(companyId, currentUserId, await issueReadSqlCondition(db, req.actor))
: [];
const badges = await svc.get(companyId, {
currentUserId: req.actor.type === "board"
? req.actor.userId ?? null
: req.actor.onBehalfOfUserId ?? null,
dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
unreadTouchedIssues: unreadIssueIds.length,
approvalReadCondition: await approvalReadSqlCondition(db, req.actor),
actor: req.actor,
});
res.json(badges);
res.json({ ...badges, unreadIssueIds });
});
return router;
+51 -5
View File
@@ -1876,6 +1876,10 @@ export interface IssueFilters {
*/
assigneeAgentId?: string | null;
participantAgentId?: string;
issueIds?: string[];
listProjection?: "index" | "rows";
parentIds?: string[];
oneChildPerParent?: boolean;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
@@ -8038,6 +8042,32 @@ export function issueService(db: Db) {
clearCheckoutRunIfTerminal,
addStopRelayCommentIfNeeded,
unreadInboxIssueIds: async (companyId: string, userId: string, readCondition: SQL<boolean>) => {
// Rank the visible Mine set before counting unread, just like the inbox.
// This projection never reads descriptions, run payloads, or recovery details.
const rows = await db.select({
id: issues.id,
}).from(issues).where(and(
eq(issues.companyId, companyId),
visibleIssueCondition(),
isNull(issues.conversationAgentId),
nonPluginOperationIssueCondition(),
ne(issues.status, "cancelled"),
touchedByUserCondition(companyId, userId),
inboxVisibleForUserCondition(companyId, userId),
readCondition,
)).orderBy(desc(issueCanonicalLastActivityAtExpr(companyId)), desc(issues.updatedAt), desc(issues.id)).limit(100);
if (rows.length === 0) return [];
// Keep correlated predicates in WHERE (as in the list's user stats).
// A single-table Drizzle SELECT projection unqualifies nested columns.
return db.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, companyId),
inArray(issues.id, rows.map((row) => row.id)),
unreadForUserCondition(companyId, userId),
readCondition,
)).then((unread) => unread.map((row) => row.id));
},
list: async (companyId: string, filters?: IssueFilters) => {
if (filters?.sortField === "id" && filters.attention) {
throw unprocessable("ID ordering is not supported for blocked attention lists");
@@ -8060,7 +8090,15 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
visibleIssueCondition(),
];
if (!filters?.q?.trim()) {
if (filters?.issueIds) {
if (filters.issueIds.length === 0) return [];
conditions.push(inArray(issues.id, filters.issueIds));
}
if (filters?.parentIds) {
if (filters.parentIds.length === 0) return [];
conditions.push(inArray(issues.parentId, filters.parentIds));
}
if (!filters?.q?.trim() && !filters?.issueIds) {
conditions.push(isNull(issues.conversationAgentId));
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
conditions.push(nonIdleSlackIssueCondition());
@@ -8218,7 +8256,14 @@ export function issueService(db: Db) {
}
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const searchOrder = sql<number>`-task_search.score`;
const issueSource = db.select(issueListSelect).from(issues);
const selectedFields = filters?.listProjection ? {
...issueListSelect,
description: sql<string | null>`NULL`,
descriptionTruncated: sql<boolean>`false`,
} : issueListSelect;
const issueSource = (filters?.oneChildPerParent
? db.selectDistinctOn([issues.parentId], selectedFields)
: db.select(selectedFields)).from(issues);
const searchedSource = hasSearch
? issueSource.innerJoin(sql`(
${taskSearchCtes(companyId, taskSearch, true, and(...conditions), filters?.readCondition)}
@@ -8228,6 +8273,7 @@ export function issueService(db: Db) {
const baseQuery = searchedSource
.where(and(...conditions))
.orderBy(
...(filters?.oneChildPerParent ? [asc(issues.parentId)] : []),
...issueListOrderBy(companyId, {
hasSearch,
priorityOrder,
@@ -8252,7 +8298,7 @@ export function issueService(db: Db) {
),
}));
const withLabels = await withIssueLabels(db, rows);
const runMap = await activeRunMapForIssues(db, withLabels);
const runMap = filters?.listProjection === "index" ? new Map() : await activeRunMapForIssues(db, withLabels);
const withRuns = withActiveRuns(withLabels, runMap);
if (withRuns.length === 0) {
return withRuns;
@@ -8298,8 +8344,8 @@ export function issueService(db: Db) {
reviewAttentionByIssueId,
blockedInboxAttentionByIssueId,
] = await Promise.all([
listIssueBlockerAttentionMap(db, companyId, withRuns),
listIssueReviewAttentionMap(db, companyId, withRuns),
filters?.listProjection === "index" ? Promise.resolve(new Map<string, IssueBlockerAttention>()) : listIssueBlockerAttentionMap(db, companyId, withRuns),
filters?.listProjection === "index" ? Promise.resolve(new Map<string, IssueReviewAttention>()) : listIssueReviewAttentionMap(db, companyId, withRuns),
includeBlockedInboxAttention
? listIssueBlockedInboxAttentionMap(db, companyId, withRuns)
: Promise.resolve(new Map<string, IssueBlockedInboxAttention>()),
+23 -11
View File
@@ -1,10 +1,11 @@
import { and, desc, eq, inArray, not } from "drizzle-orm";
import { and, desc, eq, inArray, not, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
import { isHeartbeatRunVisibleInMine, type SidebarBadges } from "@paperclipai/shared";
import { isHeartbeatRunVisibleInMine, latestFailedRunsByAgent, type SidebarBadges } from "@paperclipai/shared";
import { authorizationService, type AuthorizationActor } from "./authorization.js";
import { getHeartbeatRunReadDecisions } from "./heartbeat-run-privacy.js";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
function normalizeTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
@@ -23,6 +24,7 @@ function isDismissed(
}
export function sidebarBadgeService(db: Db) {
const authorization = authorizationService(db);
return {
get: async (
companyId: string,
@@ -31,6 +33,8 @@ export function sidebarBadgeService(db: Db) {
dismissals?: ReadonlyMap<string, number>;
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
unreadTouchedIssues?: number;
approvalReadCondition?: SQL<boolean>;
actor?: AuthorizationActor;
},
): Promise<SidebarBadges> => {
const actionableApprovals = await db
@@ -40,16 +44,21 @@ export function sidebarBadgeService(db: Db) {
and(
eq(approvals.companyId, companyId),
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
extra?.approvalReadCondition,
),
)
.then((rows) =>
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
);
const latestRunByAgent = await db
.selectDistinctOn([heartbeatRuns.agentId], {
const recentRuns = await db
.select({
id: heartbeatRuns.id,
runStatus: heartbeatRuns.status,
agentId: heartbeatRuns.agentId,
companyId: heartbeatRuns.companyId,
scopeKind: heartbeatRuns.scopeKind,
issueId: heartbeatRuns.issueId,
status: heartbeatRuns.status,
responsibleUserId: heartbeatRuns.responsibleUserId,
createdAt: heartbeatRuns.createdAt,
})
@@ -62,13 +71,16 @@ export function sidebarBadgeService(db: Db) {
not(eq(agents.status, "terminated")),
),
)
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
.orderBy(desc(heartbeatRuns.createdAt))
.limit(200);
const failedRuns = latestRunByAgent.filter((row) =>
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
&& (extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId))
const failures = latestFailedRunsByAgent(recentRuns).filter((row) =>
(extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId))
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
).length;
);
const failedRuns = extra?.actor
? (await getHeartbeatRunReadDecisions(db, authorization, extra.actor, failures)).filter(Boolean).length
: failures.length;
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
!isDismissed(
+8 -5
View File
@@ -53,14 +53,16 @@ const AssistantConnectionsPage = lazy(() => import("./pages/McpConnect").then((m
const ProductionLayout = lazy(() => import("./components/Layout.production").then((module) => ({ default: module.Layout })));
const TaskChatLab = lazy(() => import("./pages/TaskChatLab").then((module) => ({ default: module.TaskChatLab })));
const Issues = lazy(() => import("./pages/Issues").then((module) => ({ default: module.Issues })));
const loadIssues = () => import("./pages/Issues").then((module) => ({ default: module.Issues }));
const Issues = lazy(loadIssues);
let issueDetailLoad: Promise<{ default: typeof import("./pages/IssueDetail").IssueDetail }> | undefined;
const loadIssueDetail = () =>
(issueDetailLoad ??= import("./pages/IssueDetail").then((module) => ({ default: module.IssueDetail })));
const IssueDetail = lazy(loadIssueDetail);
const AgentChat = lazy(() => import("./pages/AgentChat").then((module) => ({ default: module.AgentChat })));
const IssueChatLongThreadPerf = lazy(() => import("./pages/IssueChatLongThreadPerf").then((module) => ({ default: module.IssueChatLongThreadPerf })));
const Inbox = lazy(() => import("./pages/Inbox").then((module) => ({ default: module.Inbox })));
const loadInbox = () => import("./pages/Inbox").then((module) => ({ default: module.Inbox }));
const Inbox = lazy(loadInbox);
const BootstrapSetupUxLab = lazy(() => import("./pages/BootstrapSetupUxLab").then((module) => ({ default: module.BootstrapSetupUxLab })));
const ResponsibleUserDenialUxLab = lazy(() => import("./pages/ResponsibleUserDenialUxLab").then((module) => ({ default: module.ResponsibleUserDenialUxLab })));
const CrossIssueCollaborationUxLab = lazy(() => import("./pages/CrossIssueCollaborationUxLab").then((module) => ({ default: module.CrossIssueCollaborationUxLab })));
@@ -763,9 +765,10 @@ export function App() {
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
useEffect(() => {
if (/^\/[^/]+\/(issues|tasks|inbox)(?:\/|$)/.test(pathname)) {
void loadIssueDetail().catch(() => undefined);
}
// Download the requested collection while access/company checks run. Detail
// code is already prefetched by row intent; don't compete with the cold list.
if (/^\/[^/]+\/inbox(?:\/|$)/.test(pathname)) void loadInbox().catch(() => undefined);
else if (/^\/[^/]+\/(issues|tasks)\/?$/.test(pathname)) void loadIssues().catch(() => undefined);
}, [pathname]);
return (
+2
View File
@@ -112,6 +112,7 @@ export type RuntimeRequestResolution =
| { action: "submit"; response: import("@paperclipai/adapter-utils").PaperclipQuestionResponse };
export interface HeartbeatRunListOptions {
latestFailuresOnly?: boolean;
summary?: boolean;
}
@@ -132,6 +133,7 @@ export const heartbeatsApi = {
if (agentId) searchParams.set("agentId", agentId);
if (limit) searchParams.set("limit", String(limit));
if (options.summary) searchParams.set("summary", "true");
if (options.latestFailuresOnly) searchParams.set("latestFailuresOnly", "true");
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(
`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`,
+8 -1
View File
@@ -87,6 +87,10 @@ export type ResolveRecoveryActionResponse = {
};
export type IssueListFilters = {
ids?: readonly string[];
parentIds?: readonly string[];
oneChildPerParent?: boolean;
projection?: "index" | "rows";
attention?: "blocked";
status?: string;
projectId?: string;
@@ -126,6 +130,8 @@ function issueListSearchParams(filters?: IssueListFilters) {
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
if (filters?.parentIds) params.set("parentIds", filters.parentIds.join(","));
if (filters?.oneChildPerParent) params.set("oneChildPerParent", "true");
if (filters?.assigneeAgentId)
params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId)
@@ -211,7 +217,8 @@ export const issuesApi = {
options?: RequestOptions,
) => {
const params = issueListSearchParams(filters);
params.set("view", "compact");
params.set("view", filters?.projection ?? "compact");
if (filters?.ids) params.set("ids", filters.ids.join(","));
const path = `/companies/${companyId}/issues?${params.toString()}`;
return options
? api.get<CompactIssue[]>(path, options)
+26 -3
View File
@@ -2,7 +2,7 @@ import { requiresExecutionReconciliation } from "@paperclipai/shared";
import { useState, type CSSProperties, type ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Archive, Flag } from "lucide-react";
import { Archive, Flag, CircleAlert } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@@ -42,6 +42,8 @@ export interface IssueRowProps {
appearance?: IssueRowAppearance;
/** Refreshed task rows share their anatomy at either density. */
density?: IssueRowDensity;
/** Reserve collection geometry while optional diagnostics arrive. */
stableLayout?: boolean;
/** Opt-in complete column contract for refreshed task lists. */
columnValues?: TaskRowColumnValues;
/** Interactive disclosure or selection control before the canonical status glyph. */
@@ -135,6 +137,7 @@ export function IssueRow({
presentation = "legacy",
appearance = "default",
density = "comfortable",
stableLayout = false,
columnValues,
leadingControl,
statusSlot,
@@ -294,6 +297,8 @@ export function IssueRow({
onMouseLeave={refreshed ? () => { clearHoveredCell(); setTitleFocused(false); } : undefined}
role={refreshed && columnValues ? "row" : undefined}
data-slot="task-row"
data-issue-id={issue.id}
data-issue-row-details={issue.id}
data-unread={isUnread ? "true" : undefined}
data-appearance={refreshed ? "refreshed" : undefined}
data-density={refreshed ? density : undefined}
@@ -310,6 +315,7 @@ export function IssueRow({
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
refreshed && "task-row-refreshed",
stableLayout && !refreshed && "task-row-stable",
className,
)}
>
@@ -377,7 +383,21 @@ export function IssueRow({
>
{issue.title}{titleSuffix}
</span>}
{recoveryIndicator}
{stableLayout ? (
<span data-slot="task-row-recovery">
<span className="hidden sm:inline">{recoveryIndicator}</span>
{recoveryIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} aria-label="Task recovery status" className="relative z-10 inline-flex text-muted-foreground sm:hidden">
<CircleAlert className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent>{recoveryIndicator}</TooltipContent>
</Tooltip>
) : null}
</span>
) : recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
@@ -404,7 +424,9 @@ export function IssueRow({
</>
) : (
<>
{externalObjectSummary ? (
{stableLayout ? (
<span data-slot="task-row-external"><ExternalObjectStatusSummary summary={externalObjectSummary} compact /></span>
) : externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{metadata ? <span data-slot="task-row-metadata" data-marquee-cell={refreshed ? "metadata" : undefined} className="min-w-0">{refreshed ? <CellMarquee cell="metadata">{metadata}</CellMarquee> : metadata}</span> : null}
@@ -431,6 +453,7 @@ export function IssueRow({
return (
<div
data-issue-row-details={issue.id}
onMouseEnter={onMouseEnter}
className={cn(
// No color transition on the row band: hover/selection must snap
@@ -0,0 +1,18 @@
import { createPortal } from "react-dom";
import { Button } from "./ui/button";
/** Out of document flow so a failed optional read cannot move task rows. */
export function IssueRowDetailsNotice({ error, onRetry, message = "Some task details couldn’t load.", retryLabel = "Retry details" }: {
error: Error | null;
onRetry: () => void;
message?: string;
retryLabel?: string;
}) {
if (!error) return null;
return createPortal(
<div role="alert" className="fixed bottom-4 right-4 z-50 flex max-w-sm items-center gap-3 rounded-lg border bg-card p-3 text-sm shadow-lg">
<p>{message}</p>
<Button variant="outline" size="sm" onClick={onRetry}>{retryLabel}</Button>
</div>, document.body,
);
}
+53 -1
View File
@@ -379,6 +379,33 @@ describe("IssuesList", () => {
container.remove();
});
it("waits for the initial tree but keeps loaded rows visible while new ancestors load", async () => {
const first = createIssue({ id: "child-one", parentId: "parent-one", title: "First nested task" });
const second = createIssue({ id: "child-two", parentId: "parent-two", title: "Second nested task" });
let resolveAncestors: (rows: Issue[]) => void = () => undefined;
mockIssuesApi.list.mockImplementation((_company, filters) => filters.ids && filters.projection === "index"
? new Promise<Issue[]>((resolve) => { resolveAncestors = resolve; }) : Promise.resolve([]));
const view = (rows: Issue[]) => <IssuesList issues={rows} agents={[]} projects={[]}
viewStateKey="paperclip:test-issues" enableAgentCreatedVisibilityFilter rowPresentation="task"
onUpdateIssue={() => undefined} />;
const { root, queryClient } = renderWithQueryClient(view([first]), container);
await waitForAssertion(() => expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1",
expect.objectContaining({ ids: ["parent-one"], projection: "index" }), expect.anything()));
expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull();
const parent = createIssue({ id: "parent-one", title: "First parent" });
await act(() => resolveAncestors([parent]));
await waitForAssertion(() => expect(container.textContent).toContain("First nested task"));
await act(() => root.render(<QueryClientProvider client={queryClient}><TooltipProvider>{view([first, second])}</TooltipProvider></QueryClientProvider>));
await waitForAssertion(() => expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1",
expect.objectContaining({ ids: ["parent-one", "parent-two"], projection: "index" }), expect.anything()));
expect(container.textContent).toContain("First parent");
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
await act(() => resolveAncestors([parent, createIssue({ id: "parent-two", title: "Second parent" })]));
await waitForAssertion(() => expect(container.textContent).toContain("Second nested task"));
act(() => root.unmount());
queryClient.clear();
});
it("uses the master list and legacy persistence when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
@@ -624,6 +651,29 @@ describe("IssuesList", () => {
});
});
it("shows a retryable external-status filter error instead of a false empty task list", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
mockExternalObjectsApi.getIssueSummaries.mockRejectedValue(new Error("Status lookup unavailable"));
const { root, queryClient } = renderWithQueryClient(<IssuesList
issues={[createIssue({ id: "issue-failed", title: "Matching task after retry" })]}
agents={[]} projects={[]} viewStateKey="paperclip:test-issues" rowPresentation="task"
onUpdateIssue={() => undefined}
/>, container);
await waitForAssertion(() => expect(container.textContent).toContain("Unable to filter tasks by external status"));
expect(container.textContent).not.toContain("No tasks match");
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {
"issue-failed": { total: 1, byStatusCategory: { failed: 1 }, objects: [] },
} });
await act(() => Array.from(container.querySelectorAll('button')).find((button) => button.textContent === "Retry external statuses")!.click());
await waitForAssertion(() => expect(container.textContent).toContain("Matching task after retry"));
expect(container.textContent).not.toContain("Unable to filter tasks by external status");
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(2);
act(() => root.unmount());
queryClient.clear();
});
it("renders server search results instead of filtering the full issue list locally", async () => {
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
@@ -645,6 +695,7 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
q: "server",
projection: undefined,
projectId: undefined,
limit: 200,
}, { signal: expect.any(AbortSignal) });
@@ -679,6 +730,7 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
q: "server",
projection: undefined,
projectId: undefined,
parentId: "parent-1",
limit: 200,
@@ -1577,7 +1629,7 @@ describe("IssuesList", () => {
let holdNextRead = false;
let releaseRead: (() => void) | undefined;
mockIssuesApi.list.mockImplementation(async (_companyId, filters) => {
if (filters.parentId !== parent.id) return [];
if (!filters.parentIds?.includes(parent.id)) return [];
const rows = children.slice(filters.offset ?? 0, (filters.offset ?? 0) + filters.limit);
if (holdNextRead) {
holdNextRead = false;
+34 -6
View File
@@ -1,3 +1,5 @@
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice";
import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks";
import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext";
import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context";
@@ -919,13 +921,14 @@ function StreamlinedIssuesList({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
"compact",
enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : "compact",
ISSUE_SEARCH_RESULT_LIMIT,
enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined,
],
queryFn: ({ signal }) =>
issuesApi.listCompact(selectedCompanyId!, {
projection: enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : undefined,
q: normalizedIssueSearch,
projectId,
limit: ISSUE_SEARCH_RESULT_LIMIT,
@@ -1198,6 +1201,8 @@ function StreamlinedIssuesList({
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
error: externalObjectSummariesError,
refetch: retryExternalObjectSummaries,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
issueIdsForExternalObjectSummaries,
@@ -1210,6 +1215,7 @@ function StreamlinedIssuesList({
const externalObjectFilterLoading = hasExternalObjectStatusFilters
&& externalObjectSummariesLoading
&& !externalObjectSummariesReady;
const externalObjectFilterError = hasExternalObjectStatusFilters ? externalObjectSummariesError : null;
const filtered = useMemo(() => {
const filteredByControls = applyIssueFilters(
@@ -1307,6 +1313,9 @@ function StreamlinedIssuesList({
hierarchyEnabled,
hierarchyRowBudget,
);
// Resolve the initial tree before reveal: inserting ancestors/children later would move rows.
// Hierarchy queries retain existing context during subsequent reads.
const collectionLoading = Boolean(isLoading || externalObjectFilterLoading || (hierarchyEnabled && hierarchy.isLoading));
const baseGroupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
return [{ key: "__all", label: null as string | null, items: filtered }];
@@ -1449,6 +1458,13 @@ function StreamlinedIssuesList({
viewState.nestingEnabled,
]);
const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails(
selectedCompanyId, rootRef,
flatNavEntries.filter((entry) => entry.type === "issue").map((entry) => entry.type === "issue" ? entry.issue.id : "").join(",") + ":" + renderedIssueRowLimit,
enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" && !collectionLoading,
[...issues, ...hierarchy.issues],
);
const listNavStateRef = useRef({
flatNavEntries,
selectedNavKey,
@@ -1777,6 +1793,7 @@ function StreamlinedIssuesList({
return (
<div ref={rootRef} className="space-y-4">
<IssueRowDetailsNotice error={rowDetailsError} onRetry={() => void retryRowDetails()} />
{progressSummary ? (
<SubIssueProgressSummaryStrip
summary={progressSummary}
@@ -2042,8 +2059,15 @@ function StreamlinedIssuesList({
)}
/>
{(isLoading || externalObjectFilterLoading) && <PageSkeleton variant="issues-list" />}
{collectionLoading && !externalObjectFilterError && <PageSkeleton variant="issues-list" collectionHeading={viewState.groupBy !== "none" ? "group"
: viewState.showDateGroupSeparators && issueDateSeparatorField(viewState) && rowPresentation === "task" ? "date" : null} />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{externalObjectFilterError && (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<p>Unable to filter tasks by external status: {externalObjectFilterError.message}</p>
<Button variant="outline" size="sm" onClick={retryExternalObjectSummaries}>Retry external statuses</Button>
</div>
)}
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
@@ -2055,7 +2079,7 @@ function StreamlinedIssuesList({
</p>
)}
{hierarchy.error ? <p className="text-sm text-destructive">Failed to load task hierarchy: {hierarchy.error.message}</p> : null}
{!isLoading && !externalObjectFilterLoading && visibleListRowCount === 0 && viewState.viewMode === "list" && (
{!collectionLoading && !externalObjectFilterError && visibleListRowCount === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}
message="No tasks match the current filters or search."
@@ -2064,7 +2088,7 @@ function StreamlinedIssuesList({
/>
)}
{viewState.viewMode === "board" ? (
{!collectionLoading && !externalObjectFilterError && (viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
@@ -2134,6 +2158,7 @@ function StreamlinedIssuesList({
: { roots: group.items, childMap: new Map<string, Issue[]>() };
const renderIssueRow = (issue: Issue, depth: number) => {
issue = enrichIssueRow(issue);
if (remainingRowsToRender <= 0) return null;
remainingRowsToRender -= 1;
@@ -2236,6 +2261,7 @@ function StreamlinedIssuesList({
<IssueRow
issue={issue}
presentation={rowPresentation}
stableLayout={enableAgentCreatedVisibilityFilter}
issueLinkState={issueLinkState}
selected={selectedNavKey === `issue:${issue.id}`}
onMouseEnter={() => setNavSelectionFromPointer(`issue:${issue.id}`)}
@@ -2251,7 +2277,9 @@ function StreamlinedIssuesList({
<>
{hasChildren && !isExpanded ? (
<span className="ml-1.5 text-xs text-muted-foreground">
({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""})
{enableAgentCreatedVisibilityFilter
? "(sub-tasks)"
: `(${totalDescendants} sub-task${totalDescendants !== 1 ? "s" : ""})`}
</span>
) : null}
{issueBadge ? (
@@ -2564,7 +2592,7 @@ function StreamlinedIssuesList({
</div>
)}
</div>
)}
))}
</div>
);
}
+18 -43
View File
@@ -1,6 +1,7 @@
import { Skeleton } from "@/components/ui/skeleton";
interface PageSkeletonProps {
collectionHeading?: "date" | "group" | null;
variant?:
| "list"
| "issues-list"
@@ -12,7 +13,7 @@ interface PageSkeletonProps {
| "org-chart";
}
export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
export function PageSkeleton({ variant = "list", collectionHeading }: PageSkeletonProps) {
if (variant === "dashboard") {
return (
<div className="space-y-6">
@@ -72,30 +73,6 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
);
}
if (variant === "inbox") {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-56" />
<Skeleton className="h-8 w-40" />
</div>
<div className="space-y-5">
{Array.from({ length: 3 }).map((_, section) => (
<div key={section} className="space-y-2">
<Skeleton className="h-4 w-40" />
<div className="space-y-1 border border-border">
{Array.from({ length: 3 }).map((_, row) => (
<Skeleton key={row} className="h-14 w-full rounded-none" />
))}
</div>
</div>
))}
</div>
</div>
);
}
if (variant === "org-chart") {
return (
<div className="space-y-4">
@@ -135,27 +112,25 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
);
}
if (variant === "issues-list") {
if (variant === "issues-list" || variant === "inbox") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-64" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-24" />
<div role="status" aria-label="Loading tasks" className="-mx-2 sm:mx-0">
{collectionHeading ? (
<div aria-hidden="true" className={collectionHeading === "date"
? "flex items-center justify-center py-1.5 text-(length:--text-nano)"
: "flex items-center py-1.5 text-sm"}>
<span className="invisible">Loading</span>
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full rounded-none" />
))}
) : null}
{Array.from({ length: 8 }, (_, index) => (
<div key={index} className="task-row-skeleton" aria-hidden="true">
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-4 shrink-0" />
<Skeleton className="h-4 w-2/5" />
<Skeleton className="ml-auto hidden h-4 w-20 sm:block" />
<Skeleton className="hidden h-4 w-24 sm:block" />
</div>
</div>
))}
</div>
);
}
+25 -102
View File
@@ -1,23 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { inboxDismissalsApi } from "../api/inboxDismissals";
import { approvalsApi } from "../api/approvals";
import { authApi } from "../api/auth";
import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
filterLocalInboxArchivedIssues,
useLocalInboxArchiveIssueIds,
} from "../lib/inboxArchiveCache";
import { usePublishSharedQueryData, useSharedPollingQuery } from "./useSharedPolling";
import {
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxAlerts,
saveDismissedInboxAlerts,
loadReadInboxItems,
@@ -25,11 +16,6 @@ import {
READ_ITEMS_KEY,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
const INBOX_BADGE_ISSUE_LIMIT = 500;
const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200;
const INBOX_BADGE_HOT_PATH_STALE_MS = 30_000;
export function useDismissedInboxAlerts() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
@@ -60,7 +46,7 @@ export function useInboxDismissals(companyId: string | null | undefined) {
? queryKeys.inboxDismissals(companyId)
: ["inbox-dismissals", "__disabled__"] as const;
const { data: dismissals = [] } = useQuery({
const { data: dismissals = [], isLoading, error } = useQuery({
queryKey,
queryFn: () => inboxDismissalsApi.list(companyId!),
enabled: !!companyId,
@@ -136,6 +122,8 @@ export function useInboxDismissals(companyId: string | null | undefined) {
return {
dismissals,
isLoading,
error,
dismissedAtByKey,
dismiss,
snooze,
@@ -179,97 +167,32 @@ export function useReadInboxItems() {
export function useInboxBadge(companyId: string | null | undefined) {
const locallyArchivedIssueIds = useLocalInboxArchiveIssueIds(companyId);
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
const { dismissedAtByKey } = useInboxDismissals(companyId);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
queryFn: () => approvalsApi.list(companyId!),
enabled: !!companyId,
});
const { data: joinRequests = [] } = useQuery({
queryKey: queryKeys.access.joinRequests(companyId!),
queryFn: async () => {
try {
return await accessApi.listJoinRequests(companyId!, "pending_approval");
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return [];
}
throw err;
}
},
enabled: !!companyId,
retry: false,
});
const dashboardQueryKey = queryKeys.dashboard(companyId!);
const sharedDashboard = useSharedPollingQuery({
const badgeQueryKey = queryKeys.sidebarBadges(companyId ?? "__disabled__");
const sharedBadges = useSharedPollingQuery({
companyId,
resourceKey: "dashboard",
queryKey: dashboardQueryKey,
resourceKey: "inbox-badge",
queryKey: badgeQueryKey,
enabled: !!companyId,
});
const { data: dashboard, dataUpdatedAt: dashboardUpdatedAt } = useQuery({
queryKey: dashboardQueryKey,
queryFn: () => dashboardApi.summary(companyId!),
enabled: !!companyId,
});
usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt);
const mineIssuesQueryKey = queryKeys.issues.listMineByMe(companyId!);
const sharedMineIssues = useSharedPollingQuery({
companyId,
resourceKey: "inbox-badge:mine-issues",
queryKey: mineIssuesQueryKey,
enabled: !!companyId,
});
const { data: mineIssuesRaw = [], dataUpdatedAt: mineIssuesUpdatedAt } = useQuery({
queryKey: mineIssuesQueryKey,
queryFn: () =>
issuesApi.list(companyId!, {
touchedByUserId: "me",
inboxArchivedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
limit: INBOX_BADGE_ISSUE_LIMIT,
}),
const { data: badges, dataUpdatedAt } = useQuery({
queryKey: badgeQueryKey,
queryFn: () => sidebarBadgesApi.get(companyId!),
enabled: !!companyId,
refetchOnWindowFocus: false,
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
staleTime: 30_000,
});
usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt);
usePublishSharedQueryData(sharedBadges, badges, dataUpdatedAt);
const mineIssues = useMemo(
() => getRecentTouchedIssues(filterLocalInboxArchivedIssues(companyId, mineIssuesRaw)),
[companyId, locallyArchivedIssueIds, mineIssuesRaw],
);
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const { data: heartbeatRuns = [] } = useQuery({
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT, { summary: true }),
enabled: !!companyId,
refetchOnWindowFocus: false,
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
});
return useMemo(
() =>
computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
mineIssues,
dismissedAlerts,
dismissedAtByKey,
currentUserId,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey, currentUserId],
);
return useMemo(() => {
const unreadIssueIds = badges?.unreadIssueIds ?? [];
const archivedUnread = unreadIssueIds.filter((id) => locallyArchivedIssueIds.has(id)).length;
return {
inbox: Math.max(0, (badges?.inbox ?? 0) - archivedUnread),
approvals: badges?.approvals ?? 0,
failedRuns: badges?.failedRuns ?? 0,
joinRequests: badges?.joinRequests ?? 0,
mineIssues: unreadIssueIds.length - archivedUnread,
alerts: 0,
};
}, [badges, locallyArchivedIssueIds]);
}
+4
View File
@@ -207,6 +207,8 @@ export function useIssueExternalObjectSummaries(
summaries: Map<string, ExternalObjectSummary>;
isLoading: boolean;
isReady: boolean;
error: Error | null;
refetch: () => void;
} {
const externalObjectsFeature = useExternalObjectsFeature();
const normalizedIssueIds = useMemo(
@@ -228,6 +230,8 @@ export function useIssueExternalObjectSummaries(
summaries,
isLoading: enabled && query.isLoading,
isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess),
error: query.error,
refetch: () => { void query.refetch(); },
};
}
+31 -27
View File
@@ -1,50 +1,54 @@
import { loadIssueHierarchyChildren } from "../lib/issue-hierarchy-context";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
export function useIssueHierarchyContext(companyId: string | null, matches: Issue[], collapsedParents: string[], enabled: boolean, rowBudget: number) {
const parents = [...new Set(matches.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)))];
const ancestorQueries = useQueries({ queries: parents.map((parentId) => ({
queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-ancestors", parentId],
enabled: enabled && Boolean(companyId),
const parents = [...new Set(matches.map((issue) => issue.parentId).filter((id): id is string => Boolean(id)))].sort();
const ancestors = useQuery<Issue[]>({
queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-index-ancestors", parents],
enabled: enabled && Boolean(companyId) && parents.length > 0,
staleTime: 30_000,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const ancestors: Issue[] = [];
placeholderData: (previous) => previous?.filter((issue) => issue.companyId === companyId),
queryFn: async ({ signal }) => {
const rows: Issue[] = [];
const visited = new Set<string>();
let id: string | null = parentId;
while (id && !visited.has(id)) {
visited.add(id);
try {
const parent: Issue = await issuesApi.get(id, { signal });
if (parent.companyId !== companyId) break;
ancestors.push(parent);
id = parent.parentId;
} catch (error) {
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) break;
throw error;
let frontier = parents;
while (frontier.length > 0) {
const next: string[] = [];
for (let offset = 0; offset < frontier.length; offset += 100) {
const ids = frontier.slice(offset, offset + 100);
ids.forEach((id) => visited.add(id));
const batch = await issuesApi.listCompact(companyId!, { ids, projection: "index", limit: 100, includeRoutineExecutions: true }, { signal });
for (const issue of batch) {
if (issue.companyId !== companyId) continue;
rows.push(issue as Issue);
if (issue.parentId && !visited.has(issue.parentId)) next.push(issue.parentId);
}
}
frontier = [...new Set(next)].filter((id) => !visited.has(id));
}
return ancestors;
return rows;
},
})) });
});
const children = useQuery<Awaited<ReturnType<typeof loadIssueHierarchyChildren>>>({
queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-children", matches.map((issue) => issue.id), collapsedParents, rowBudget],
queryKey: [...queryKeys.issues.list(companyId ?? "__unscoped__"), "hierarchy-index-children", matches.map((issue) => issue.id), collapsedParents, rowBudget],
enabled: enabled && Boolean(companyId) && matches.length > 0,
staleTime: 30_000,
placeholderData: (previous) => previous ? {
...previous, issues: previous.issues.filter((issue) => issue.companyId === companyId),
} : undefined,
queryFn: ({ signal }) => loadIssueHierarchyChildren(companyId!, matches, collapsedParents, rowBudget,
(parentId, limit, offset) => issuesApi.list(companyId!, { parentId, limit, offset, includeRoutineExecutions: true }, { signal })),
async (parentIds, limit, offset, oneChildPerParent) => await issuesApi.listCompact(companyId!, {
parentIds, oneChildPerParent, projection: "index", limit, offset, includeRoutineExecutions: true,
}, { signal }) as Issue[]),
});
const queries = [...ancestorQueries, children];
return {
issues: [...ancestorQueries.flatMap((query) => query.data ?? []), ...(children.data?.issues ?? [])],
issues: [...(ancestors.data ?? []), ...(children.data?.issues ?? [])],
hasMore: children.data?.hasMore ?? false,
isFetching: children.isFetching,
error: queries.find((query) => query.error)?.error,
isLoading: ancestors.isLoading || children.isLoading,
isFetching: ancestors.isFetching || children.isFetching,
error: ancestors.error ?? children.error,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { mergeIssueRowDetails } from "./useIssueRowDetails";
const index = {
id: "task-1", companyId: "company-1", title: "Current title", status: "todo",
updatedAt: new Date("2026-10-26T10:00:00Z"), isUnreadForMe: true, listProjection: "index",
} as Issue;
describe("visible task row enrichment", () => {
it("keeps the index's ordering, read state, and title when diagnostics arrive", () => {
const details = {
...index, title: "Old title", status: "done", isUnreadForMe: false,
activeRecoveryAction: { id: "recovery-1" },
} as Issue;
const merged = mergeIssueRowDetails(index, details);
expect(merged).toMatchObject({
title: "Current title", status: "todo", isUnreadForMe: true, listProjection: "index",
activeRecoveryAction: { id: "recovery-1" },
});
expect(merged.updatedAt).toBe(index.updatedAt);
});
it("rejects stale diagnostics and a different task or company", () => {
for (const overrides of [
{ updatedAt: new Date("2026-10-26T09:00:00Z") },
{ id: "task-2" },
{ companyId: "company-2" },
]) {
expect(mergeIssueRowDetails(index, { ...index, ...overrides })).toBe(index);
}
});
it("accepts newer diagnostics without replacing the index's order or content", () => {
const merged = mergeIssueRowDetails(index, {
...index, updatedAt: new Date("2026-10-26T10:00:01Z"), title: "Changed during loading",
activeRecoveryAction: { id: "new-recovery" },
} as Issue);
expect(merged.title).toBe(index.title);
expect(merged.updatedAt).toBe(index.updatedAt);
expect(merged.activeRecoveryAction?.id).toBe("new-recovery");
});
});
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
/** Enrich visible rows without replacing the index that owns membership and order. */
export function mergeIssueRowDetails(issue: Issue, details: Issue | undefined): Issue {
if (!details || details.id !== issue.id || details.companyId !== issue.companyId
|| new Date(details.updatedAt).getTime() < new Date(issue.updatedAt).getTime()) return issue;
return {
...issue,
blockerAttention: details.blockerAttention,
reviewAttention: details.reviewAttention,
activeRecoveryAction: details.activeRecoveryAction,
interruption: details.interruption,
successfulRunHandoff: details.successfulRunHandoff,
scheduledRetry: details.scheduledRetry,
};
}
export function useIssueRowDetails(
companyId: string | null | undefined,
root: RefObject<HTMLElement | null>,
rowIdentity: string,
enabled: boolean,
issues: readonly Issue[],
) {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const [saved, setSaved] = useState<{ companyId: string; rows: Map<string, Issue> } | null>(null);
useEffect(() => {
const element = root.current;
if (!enabled || !element) {
setVisibleIds([]);
return;
}
const nodes = [...element.querySelectorAll<HTMLElement>("[data-issue-row-details]")];
const visible = new Set<string>();
const publish = () => {
const next = [...visible].sort().slice(0, 50);
setVisibleIds((previous) => previous.join(",") === next.join(",") ? previous : next);
};
if (typeof IntersectionObserver === "undefined") {
for (const node of nodes.slice(0, 50)) visible.add(node.dataset.issueRowDetails!);
publish();
return;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset.issueRowDetails!;
if (entry.isIntersecting) visible.add(id);
else visible.delete(id);
}
publish();
});
for (const node of nodes) observer.observe(node);
return () => observer.disconnect();
}, [companyId, root, rowIdentity, enabled]);
const versions = new Map<string, number>();
for (const issue of issues) {
versions.set(issue.id, Math.max(versions.get(issue.id) ?? 0, new Date(issue.updatedAt).getTime()));
}
const visibleVersions = visibleIds.map((id) => [id, versions.get(id)]);
const { data, error, refetch } = useQuery({
queryKey: [...queryKeys.issues.list(companyId ?? "__disabled__"), "visible-row-details", visibleVersions],
queryFn: ({ signal }) => issuesApi.listCompact(companyId!, {
ids: visibleIds, projection: "rows", includeRoutineExecutions: true, limit: 50,
}, { signal }),
enabled: enabled && !!companyId && visibleIds.length > 0,
staleTime: 30_000,
});
useEffect(() => {
if (!companyId || !data) return;
setSaved((previous) => {
const rows = new Map(previous?.companyId === companyId ? previous.rows : []);
for (const issue of data) {
const previousRow = rows.get(issue.id);
if (!previousRow || new Date(issue.updatedAt).getTime() >= new Date(previousRow.updatedAt).getTime()) {
rows.set(issue.id, issue as Issue);
}
}
return { companyId, rows };
});
}, [companyId, data]);
const enrichIssueRow = useMemo(() => (issue: Issue) => mergeIssueRowDetails(
issue, saved && saved.companyId === companyId ? saved.rows.get(issue.id) : undefined,
), [companyId, saved]);
return { enrichIssueRow, error, retry: refetch };
}
+36
View File
@@ -3679,6 +3679,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
:root, .dark {
--task-row-height-comfortable: calc(var(--spacing) * 11);
--task-row-height-compact: calc(var(--spacing) * 8);
--task-row-recovery-width: calc(var(--spacing) * 32);
--task-row-external-width: calc(var(--spacing) * 10);
--task-row-padding-inline: calc(var(--spacing) * 4);
--task-row-gap: calc(var(--spacing) * 2);
--task-row-indent-step: calc(var(--spacing) * 6);
@@ -3716,6 +3718,40 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
}
.task-row-group + .task-row-group { border-top: 0; }
.task-row-stable, .task-row-skeleton {
height: var(--task-row-height-comfortable);
min-height: var(--task-row-height-comfortable);
}
.task-row-stable { align-items: center; }
.task-row-stable [data-slot="task-row-leading"] { align-items: center; padding-top: 0; }
.task-row-stable [data-slot="task-row-title"] {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-row-stable [data-slot="task-row-recovery"] {
width: var(--task-row-control-size);
flex-shrink: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.task-row-stable [data-slot="task-row-external"] {
width: var(--task-row-external-width);
flex-shrink: 0;
}
.task-row-stable [data-slot="task-row-metadata"] {
width: var(--task-row-time-width);
flex-shrink: 0;
overflow: hidden;
white-space: nowrap;
}
.task-row-stable [data-slot="task-row-content"] { align-self: center; }
@media (min-width: 640px) {
.task-row-stable [data-slot="task-row-recovery"] { width: var(--task-row-recovery-width); }
}
.task-row-skeleton { display: flex; align-items: center; gap: var(--task-row-gap); padding-inline: var(--task-row-padding-inline); }
.task-row-refreshed {
--task-row-height: var(--task-row-height-comfortable);
--task-row-guide-end-gap: calc((var(--task-row-height) - var(--task-row-control-size)) / 2);
+1 -150
View File
@@ -3,7 +3,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import type {
Approval,
DashboardSummary,
ExecutionWorkspace,
ExternalObjectSummary,
HeartbeatRun,
@@ -17,7 +16,6 @@ import {
buildInboxIssueGroupCreateDefaults,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
@@ -283,130 +281,11 @@ function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): Ex
};
}
const dashboard: DashboardSummary = {
companyId: "company-1",
agents: {
active: 1,
running: 0,
paused: 0,
error: 1,
},
tasks: {
open: 1,
inProgress: 0,
blocked: 0,
done: 0,
},
costs: {
monthSpendCents: 900,
monthBudgetCents: 1000,
monthUtilizationPercent: 90,
},
pendingApprovals: 1,
budgets: {
activeIncidents: 0,
pendingApprovals: 0,
pausedAgents: 0,
pausedProjects: 0,
},
runActivity: [],
};
describe("inbox helpers", () => {
beforeEach(() => {
storage.clear();
});
it.each([
{ currentUserId: "user-1", expected: 1 },
{ currentUserId: "user-2", expected: 1 },
{ currentUserId: "local-board", expected: 1 },
{ currentUserId: null, expected: 0 },
])("counts only personal failed runs for $currentUserId", ({ currentUserId, expected }) => {
const result = computeInboxBadgeData({
approvals: [], joinRequests: [], dashboard, mineIssues: [],
dismissedAlerts: new Set(), dismissedAtByKey: new Map(), currentUserId,
heartbeatRuns: [
{ ...makeRun("own", "failed", "2026-03-11T01:00:00Z"), responsibleUserId: "user-1" },
{ ...makeRun("other", "timed_out", "2026-03-11T01:00:00Z", "agent-2"), responsibleUserId: "user-2" },
{ ...makeRun("unowned", "failed", "2026-03-11T01:00:00Z", "agent-3"), responsibleUserId: null },
{ ...makeRun("old-own", "failed", "2026-03-11T01:00:00Z", "shared-agent"), responsibleUserId: "user-1" },
{ ...makeRun("new-other", "succeeded", "2026-03-11T02:00:00Z", "shared-agent"), responsibleUserId: "user-2" },
],
});
expect(result.failedRuns).toBe(expected);
expect(result.inbox).toBe(expected);
expect(result.alerts).toBe(1); // The budget alert; run failures already describe agent errors in All.
});
it("counts the same inbox sources the badge uses", () => {
const result = computeInboxBadgeData({
approvals: [
{ ...makeApproval("pending"), requestedByUserId: "user-1" },
{ ...makeApproval("approved"), requestedByUserId: "user-2" },
],
joinRequests: [makeJoinRequest("join-1")],
dashboard,
heartbeatRuns: [
makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"),
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
mineIssues: [makeIssue("1", true)],
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map<string, number>(),
currentUserId: "user-1",
});
expect(result).toEqual({
inbox: 5,
approvals: 1,
failedRuns: 2,
joinRequests: 1,
mineIssues: 1,
alerts: 1,
});
});
it("drops dismissed runs and alerts from the computed badge", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
mineIssues: [],
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
currentUserId: "user-1",
});
expect(result).toEqual({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 0,
mineIssues: 0,
alerts: 0,
});
});
it("excludes read mine issues from the inbox badge count", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [],
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map(),
currentUserId: "user-1",
});
expect(result.mineIssues).toBe(1);
expect(result.inbox).toBe(1);
expect(result.alerts).toBe(2);
});
it("resurfaces non-issue items when they change after dismissal", () => {
const dismissedAtByKey = buildInboxDismissedAtByKey([
{
@@ -480,7 +359,7 @@ describe("inbox helpers", () => {
]);
});
it("surfaces agent-requested actionable approvals in mine and the badge", () => {
it("surfaces agent-requested actionable approvals in mine", () => {
const approvals = [
{
...makeApprovalWithTimestamps("approval-agent-requested", "pending", "2026-03-11T02:00:00.000Z"),
@@ -496,34 +375,6 @@ describe("inbox helpers", () => {
"approval-agent-requested",
]);
const result = computeInboxBadgeData({
approvals,
joinRequests: [],
dashboard,
heartbeatRuns: [],
mineIssues: [],
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map(),
currentUserId: "user-1",
});
expect(result.approvals).toBe(1);
});
it("does not count company-wide alerts in the personal inbox badge", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [],
mineIssues: [],
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map(),
currentUserId: "user-1",
});
expect(result.alerts).toBe(2);
expect(result.inbox).toBe(0);
});
it("mixes approvals into the inbox feed by most recent activity", () => {
+8 -84
View File
@@ -1,7 +1,5 @@
import { isHeartbeatRunVisibleInMine } from "@paperclipai/shared";
import type {
Approval,
DashboardSummary,
HeartbeatRun,
InboxDismissal,
Issue,
@@ -17,7 +15,6 @@ import {
import { formatAssigneeUserLabel } from "./assignees";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
@@ -78,15 +75,6 @@ export type InboxWorkItem =
joinRequest: JoinRequest;
};
export interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
mineIssues: number;
alerts: number;
}
export interface InboxWorkItemGroup {
key: string;
label: string | null;
@@ -428,19 +416,22 @@ export function shouldResetInboxWorkspaceGrouping(
export function matchesInboxIssueSearch(
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId"> & { id?: string },
query: string,
{
isolatedWorkspacesEnabled = false,
matchedIssueIds,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: InboxWorkspaceGroupingOptions & {
isolatedWorkspacesEnabled?: boolean;
matchedIssueIds?: ReadonlySet<string>;
} = {},
): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return true;
if (issue.id && matchedIssueIds?.has(issue.id)) return true;
if (issue.title.toLowerCase().includes(normalizedQuery)) return true;
if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true;
if (issue.description?.toLowerCase().includes(normalizedQuery)) return true;
@@ -458,6 +449,7 @@ export function getArchivedInboxSearchIssues({
visibleIssues,
searchableIssues,
query,
matchedIssueIds,
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
@@ -466,6 +458,7 @@ export function getArchivedInboxSearchIssues({
visibleIssues: Issue[];
searchableIssues: Issue[];
query: string;
matchedIssueIds?: ReadonlySet<string>;
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
@@ -479,6 +472,7 @@ export function getArchivedInboxSearchIssues({
.filter((issue) => !visibleIssueIds.has(issue.id))
.filter((issue) =>
matchesInboxIssueSearch(issue, normalizedQuery, {
matchedIssueIds,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
@@ -725,20 +719,7 @@ export function getInboxKeyboardSelectionIndex(
: Math.max(previousIndex - 1, 0);
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
export { latestFailedRunsByAgent as getLatestFailedRunsByAgent } from "@paperclipai/shared";
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
@@ -1257,60 +1238,3 @@ export function shouldShowInboxSection({
if (tab === "unread") return showOnUnread;
return showOnAll;
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
mineIssues,
dismissedAlerts,
dismissedAtByKey,
currentUserId,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
mineIssues: Issue[];
dismissedAlerts: Set<string>;
dismissedAtByKey: ReadonlyMap<string, number>;
currentUserId?: string | null;
}): InboxBadgeData {
const actionableApprovals = approvals.filter(
(approval) =>
isApprovalVisibleInMine(approval, currentUserId) &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
).length;
const visibleFailedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
);
const failedRuns = visibleFailedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
).length;
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
const showAggregateAgentError =
agentErrorCount > 0 &&
visibleFailedRuns.length === 0 &&
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissedAlerts.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
// The inbox badge reflects personal/actionable work, not company-wide health alerts.
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues,
approvals: actionableApprovals,
failedRuns,
joinRequests: visibleJoinRequests,
mineIssues: visibleMineIssues,
alerts,
};
}
+29 -10
View File
@@ -23,28 +23,47 @@ describe("bounded hierarchy reads", () => {
it("caps rows and requests, and shares overlapping matching subtrees", async () => {
const root = task("root");
const child = task("child", "root");
const calls: string[] = [];
const read = async (id: string, limit: number, offset: number) => {
calls.push(id);
const rows = id === "root" ? [child] : id === "child"
? Array.from({ length: 500 }, (_, index) => task(`nested-${index}`, "child")) : [];
const calls: string[][] = [];
const read = async (ids: string[], limit: number, offset: number) => {
calls.push(ids);
const rows = [child, ...Array.from({ length: 500 }, (_, index) => task(`nested-${index}`, "child"))].filter((issue) => ids.includes(issue.parentId!));
return rows.slice(offset, offset + limit);
};
const first = await loadIssueHierarchyChildren("company", [root, child], [], 5, read);
expect(first.issues).toHaveLength(5);
expect(first.hasMore).toBe(true);
expect(calls).toEqual(["root", "child"]);
expect(calls).toEqual([["root", "child"]]);
calls.length = 0;
expect((await loadIssueHierarchyChildren("company", [root, child], [], 10, read)).issues).toHaveLength(10);
expect(calls).toEqual(["root", "child"]);
expect(calls).toEqual([["root", "child"]]);
});
it("probes collapsed parents without fetching their tree and rejects foreign-company rows", async () => {
const calls: Array<[string, number]> = [];
it("reads collapsed parents without traversing their tree and rejects foreign-company rows", async () => {
const calls: Array<[string[], number]> = [];
const rows = await loadIssueHierarchyChildren("company", [task("root")], ["root"], 5,
async (id, limit) => { calls.push([id, limit]); return [task("child", "root")]; });
expect(rows.issues.map((issue) => issue.id)).toEqual(["child"]);
expect(calls).toEqual([["root", 1]]);
expect(calls).toEqual([[["root"], 1]]);
expect(await loadIssueHierarchyChildren("company", [task("root")], [], 1,
async () => [{ ...task("foreign", "root"), companyId: "other" }])).toEqual({ issues: [], hasMore: true });
});
it("keeps a large collapsed tree from spending an expanded sibling's budget", async () => {
const hidden = Array.from({ length: 200 }, (_, index) => task(`hidden-${index}`, "collapsed"));
const visible = task("visible", "expanded");
const result = await loadIssueHierarchyChildren("company", [task("collapsed"), task("expanded")], ["collapsed"], 2,
async (ids, limit, offset) => [...hidden, visible].filter((row) => ids.includes(row.parentId!)).slice(offset, offset + limit));
expect(result.issues.map((row) => row.id)).toEqual(["hidden-0", "visible"]);
expect(result.hasMore).toBe(false);
});
it("checks many collapsed parents in one bounded request", async () => {
const parents = Array.from({ length: 30 }, (_, index) => task(`parent-${index}`));
const calls: unknown[] = [];
const result = await loadIssueHierarchyChildren("company", parents, parents.map((row) => row.id), 100,
async (ids, limit, offset, oneChildPerParent) => {
calls.push({ ids, limit, offset, oneChildPerParent });
return ids.map((id) => task(`child-${id}`, id));
});
expect(calls).toEqual([{ ids: parents.map((row) => row.id), limit: 30, offset: 0, oneChildPerParent: true }]);
expect(result.issues).toHaveLength(30);
expect(result.hasMore).toBe(false);
});
});
+49 -18
View File
@@ -31,34 +31,65 @@ export function includeIssueHierarchyContext(matches: Issue[], context: Issue[])
return [...result.values()];
}
/** Fetch each parent once and stop at the collection's current rendering budget. */
/** Batch each breadth-first frontier; leaf-heavy lists need one read, not one per task. */
export async function loadIssueHierarchyChildren(
companyId: string,
matches: Issue[],
collapsedParents: string[],
rowBudget: number,
readChildren: (parentId: string, limit: number, offset: number) => Promise<Issue[]>,
readChildren: (parentIds: string[], limit: number, offset: number, oneChildPerParent?: boolean) => Promise<Issue[]>,
): Promise<{ issues: Issue[]; hasMore: boolean }> {
const context: Issue[] = [];
const queue = matches.map((issue) => ({ issue, offset: 0 }));
const visited = new Set<string>();
const fetched = new Set<string>();
const collapsed = new Set(collapsedParents);
let requests = 0;
while (queue.length > 0 && context.length < rowBudget && requests < rowBudget) {
const { issue, offset } = queue.shift()!;
if (offset === 0 && visited.has(issue.id)) continue;
visited.add(issue.id);
const collapsed = collapsedParents.includes(issue.id);
const limit = collapsed ? 1 : Math.min(100, rowBudget - context.length);
const rows = await readChildren(issue.id, limit, offset);
requests += 1;
for (const child of rows) {
if (child.companyId !== companyId || fetched.has(child.id)) continue;
fetched.add(child.id);
context.push(child);
if (!collapsed) queue.push({ issue: child, offset: 0 });
let loadedRows = 0;
let probes = 0;
let moreCollapsedMarkers = false;
let frontier = [...new Set(matches.map((issue) => issue.id))];
while (frontier.length > 0 && loadedRows < rowBudget) {
const next: string[] = [];
const collapsedFrontier = frontier.filter((id) => collapsed.has(id) && !visited.has(id));
const collapsedIds = collapsedFrontier.slice(0, Math.max(0, rowBudget - probes));
moreCollapsedMarkers ||= collapsedIds.length < collapsedFrontier.length;
collapsedFrontier.forEach((id) => visited.add(id));
// One child is enough to keep a collapsed parent's expand affordance. These
// markers must not spend the budget reserved for expanded, readable rows.
for (let start = 0; start < collapsedIds.length; start += 100) {
const batch = collapsedIds.slice(start, start + 100);
const markers = await readChildren(batch, batch.length, 0, true);
probes += batch.length;
for (const child of markers) {
if (child.companyId !== companyId || fetched.has(child.id)) continue;
fetched.add(child.id);
context.push(child);
}
}
if (!collapsed && rows.length === limit) queue.unshift({ issue, offset: offset + rows.length });
for (let start = 0; start < frontier.length; start += 100) {
const parentIds = frontier.slice(start, start + 100).filter((id) => !visited.has(id));
if (parentIds.length === 0) continue;
parentIds.forEach((id) => visited.add(id));
let offset = 0;
while (loadedRows < rowBudget) {
if (requests >= rowBudget) return { issues: context, hasMore: true };
requests += 1;
const limit = Math.min(100, rowBudget - loadedRows);
const rows = await readChildren(parentIds, limit, offset);
for (const child of rows) {
if (child.companyId !== companyId || fetched.has(child.id)) continue;
fetched.add(child.id);
context.push(child);
loadedRows += 1;
if (!visited.has(child.id)) next.push(child.id);
}
if (rows.length < limit) break;
offset += rows.length;
if (loadedRows >= rowBudget) return { issues: context, hasMore: true };
}
if (loadedRows >= rowBudget && (start + 100 < frontier.length || next.length > 0)) return { issues: context, hasMore: true };
}
frontier = [...new Set(next)].filter((id) => !visited.has(id));
}
return { issues: context, hasMore: queue.length > 0 };
return { issues: context, hasMore: moreCollapsedMarkers || frontier.length > 0 };
}
+2 -1
View File
@@ -38,7 +38,8 @@ function isCompleteIssueSnapshot(value: unknown): value is Issue {
if (typeof value !== "object" || value === null) return false;
const issue = value as Partial<Issue>;
return (
isNonEmptyString(issue.id)
issue.listProjection === undefined
&& isNonEmptyString(issue.id)
&& isNonEmptyString(issue.companyId)
&& typeof issue.title === "string"
&& typeof issue.status === "string"
+11
View File
@@ -88,6 +88,17 @@ describe("prefetchIssueComments", () => {
expect(issuesApi.listComments).toHaveBeenCalledTimes(1);
});
it.each(["index", "rows"] as const)("fetches the full task when navigating from a %s projection", async (listProjection) => {
const issue = createIssue();
vi.mocked(issuesApi.get).mockResolvedValue(issue);
vi.mocked(issuesApi.listComments).mockResolvedValue([]);
await prefetchIssueDetailForNavigation(queryClient, issue.identifier!, {
issue: { ...issue, listProjection, description: null },
});
expect(issuesApi.get).toHaveBeenCalledWith(issue.identifier!);
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toEqual(issue);
});
it("seeds both the detail snapshot and the first comments page for navigation", async () => {
const issue = createIssue();
const firstPage = [createComment({ id: "c1" })];
+144 -16
View File
@@ -17,9 +17,12 @@ const routerMock = vi.hoisted(() => ({
location: { pathname: "/", search: "", hash: "" },
navigate: vi.fn(),
}));
const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" as string | null }));
const externalObjectMocks = vi.hoisted(() => ({
summaries: new Map(),
error: null as Error | null,
retry: vi.fn(),
}));
const apiMocks = vi.hoisted(() => ({
@@ -103,7 +106,7 @@ vi.mock("../api/projects", () => ({
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
useCompany: () => companyState,
}));
vi.mock("../context/BreadcrumbContext", () => ({
@@ -136,7 +139,9 @@ vi.mock("../hooks/useIssueExternalObjects", () => ({
useIssueExternalObjectSummaries: () => ({
summaries: externalObjectMocks.summaries,
isLoading: false,
isReady: true,
isReady: !externalObjectMocks.error,
error: externalObjectMocks.error,
refetch: externalObjectMocks.retry,
}),
}));
@@ -338,8 +343,11 @@ function createFailedRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
}
function resetInboxApiMocks() {
companyState.selectedCompanyId = "company-1";
for (const mock of Object.values(apiMocks)) mock.mockReset();
externalObjectMocks.summaries.clear();
externalObjectMocks.error = null;
externalObjectMocks.retry.mockReset();
routerMock.location.pathname = "/";
routerMock.location.search = "";
routerMock.location.hash = "";
@@ -900,6 +908,135 @@ describe("Inbox toolbar", () => {
});
});
it("waits for the task index before revealing faster failed-run rows", async () => {
routerMock.location.pathname = "/inbox/mine";
let release: (rows: Issue[]) => void = () => undefined;
apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "index"
? new Promise<Issue[]>((resolve) => { release = resolve; }) : Promise.resolve([]));
apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(async () => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled());
expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull();
expect(container.querySelector('[data-inbox-item]')).toBeNull();
await act(async () => release([createIssue({ title: "Delayed task index row" })]));
await vi.waitFor(() => expect(container.textContent).toContain("Delayed task index row"));
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
act(() => root.unmount());
});
it("can select a company after a cold inbox mount", async () => {
companyState.selectedCompanyId = null;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
const render = () => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
await act(render);
await vi.waitFor(() => expect(container.textContent).toContain("Select an organization"));
companyState.selectedCompanyId = "company-1";
apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]);
await act(render);
await vi.waitFor(() => expect(container.textContent).toContain("Cold company task"));
act(() => root.unmount());
});
it("keeps more than 25 description-only matches in Mine with archive actions", async () => {
routerMock.location.pathname = "/inbox/mine";
const rows = Array.from({ length: 30 }, (_, index) => createIssue({ id: `source-${index}`, title: `Source task ${index}`, identifier: `PAP-${index}`, listProjection: "index" }));
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.q && !filters.ids ? [] : rows));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30));
await act(() => {
const input = container.querySelector<HTMLInputElement>('input[type="search"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "only-in-description");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ q: "only-in-description", ids: expect.arrayContaining(rows.map((row) => row.id)), projection: "index", limit: 100 }), expect.anything()));
await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30));
expect(container.querySelectorAll('button[aria-label="Archive"]')).toHaveLength(30);
expect(container.textContent).not.toContain("Other matching tasks");
act(() => root.unmount());
});
it("keeps rows readable and offers retry when optional details fail", async () => {
routerMock.location.pathname = "/inbox/mine";
let failDetails = true;
apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "rows" && failDetails
? Promise.reject(new Error("Details unavailable")) : Promise.resolve([createIssue({ title: "Readable task" })]));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(document.body.textContent).toContain("Some task details couldn’t load."));
expect(container.textContent).toContain("Readable task");
failDetails = false;
await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry details")!.click());
await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some task details couldn’t load."));
expect(container.textContent).toContain("Readable task");
act(() => root.unmount());
});
it("refreshes diagnostics when a visible index row changes without changing its ID", async () => {
routerMock.location.pathname = "/inbox/mine";
const row = createIssue({ title: "Live task", listProjection: "index" });
apiMocks.issuesList.mockResolvedValue([row]);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
const detailsReads = () => apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "rows").length;
await vi.waitFor(() => expect(detailsReads()).toBe(1));
const updated = { ...row, updatedAt: new Date(new Date(row.updatedAt).getTime() + 1000) };
apiMocks.issuesList.mockResolvedValue([updated]);
await act(() => {
queryClient.setQueriesData({ predicate: (query) => query.queryKey.includes("index") }, [updated]);
});
await vi.waitFor(() => expect(detailsReads()).toBe(2));
expect(container.textContent).toContain("Live task");
act(() => root.unmount());
});
it.each(["approvalsList", "joinRequestsList", "heartbeatRunsList"] as const)(
"keeps available tasks visible when %s fails and during retry", async (source) => {
routerMock.location.pathname = "/inbox/mine";
apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Available task" })]);
apiMocks[source].mockRejectedValue(new Error("Optional source unavailable"));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain("Available task"));
expect(document.body.textContent).toContain("Some inbox items couldn’t load.");
let resolveRetry: (rows: []) => void = () => undefined;
apiMocks[source].mockImplementation(() => new Promise((resolve) => { resolveRetry = resolve; }));
await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry missing items")!.click());
await vi.waitFor(() => expect(apiMocks[source]).toHaveBeenCalledTimes(2));
expect(container.textContent).toContain("Available task");
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
await act(() => resolveRetry([]));
await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load."));
act(() => root.unmount());
},
);
it("shows an error and retry instead of an endless skeleton when a required external status filter fails", async () => {
routerMock.location.pathname = "/inbox/mine";
localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({
version: 1, companyId: "company-1", collectionKey: "inbox", columns: [],
viewState: { issueFilters: { externalObjectStatuses: ["failed"] } },
}));
apiMocks.experimentalSettings.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true });
apiMocks.issuesList.mockResolvedValue([createIssue()]);
externalObjectMocks.error = new Error("External statuses unavailable");
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain("External statuses unavailable"));
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
await act(() => Array.from(container.querySelectorAll('button')).find((button) => button.textContent === "Retry")!.click());
expect(externalObjectMocks.retry).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("requests live descendant summaries for issue rows", async () => {
routerMock.location.pathname = "/inbox/mine";
@@ -916,20 +1053,11 @@ describe("Inbox toolbar", () => {
);
});
await vi.waitFor(() => {
expect(apiMocks.issuesList).toHaveBeenCalledTimes(3);
});
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([
true,
true,
true,
]);
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.limit)).toEqual([
500,
500,
500,
]);
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledTimes(1));
expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
touchedByUserId: "me", inboxArchivedByUserId: "me", projection: "index",
includeLiveDescendantSummary: true, limit: 100, sortField: "updated", sortDir: "desc",
}));
act(() => {
root.unmount();
+127 -55
View File
@@ -1,3 +1,5 @@
import { useIssueRowDetails } from "../hooks/useIssueRowDetails";
import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -191,7 +193,7 @@ import {
} from "../lib/task-date-groups";
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
const INBOX_ISSUE_LIST_LIMIT = 500;
const INBOX_ISSUE_LIST_LIMIT = 100;
const INBOX_HOT_PATH_STALE_MS = 30_000;
const INBOX_COLLECTION_KEY = "inbox";
@@ -847,7 +849,7 @@ function StreamlinedInbox({
const location = useLocation();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { data: experimentalSettings } = useQuery({
const { data: experimentalSettings, isLoading: isSettingsLoading } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
@@ -865,7 +867,7 @@ function StreamlinedInbox({
() => loadInboxCollectionPreferences(selectedCompanyId).columns,
);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { dismissedAtByKey, dismiss: dismissInboxItem, isLoading: isDismissalsLoading, error: dismissalsError } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
@@ -890,18 +892,18 @@ function StreamlinedInbox({
[surfaceLabel, location.pathname, location.search, location.hash],
);
const { data: session } = useQuery({
const { data: session, isLoading: isSessionLoading } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: agents } = useQuery({
const { data: agents, isLoading: isAgentsLoading } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
const { data: projects, isLoading: isProjectsLoading } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
@@ -913,7 +915,7 @@ function StreamlinedInbox({
});
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true;
const { data: executionWorkspaces = [] } = useQuery({
const { data: executionWorkspaces = [], isLoading: isWorkspacesLoading } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
: ["execution-workspaces", "__disabled__"],
@@ -956,6 +958,7 @@ function StreamlinedInbox({
const {
data: joinRequests = [],
isLoading: isJoinRequestsLoading,
error: joinRequestsError,
} = useQuery({
queryKey: queryKeys.access.joinRequests(selectedCompanyId!),
queryFn: async () => {
@@ -977,96 +980,104 @@ function StreamlinedInbox({
companyId: selectedCompanyId,
resourceKey: "dashboard",
queryKey: dashboardQueryKey,
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && tab === "all",
});
const { data: dashboard, isLoading: isDashboardLoading, dataUpdatedAt: dashboardUpdatedAt } = useQuery({
queryKey: dashboardQueryKey,
queryFn: () => dashboardApi.summary(selectedCompanyId!),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && tab === "all",
});
usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt);
const inboxIssuesQueryKey = [...queryKeys.issues.list(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const;
const sharedInboxIssues = useSharedPollingQuery<Issue[]>({
companyId: selectedCompanyId,
resourceKey: "inbox:issues",
queryKey: inboxIssuesQueryKey,
enabled: !!selectedCompanyId,
});
const { data: issues, isLoading: isIssuesLoading, dataUpdatedAt: issuesUpdatedAt } = useQuery({
queryKey: inboxIssuesQueryKey,
queryFn: () =>
issuesApi.listCompact(selectedCompanyId!, {
includeRoutineExecutions: true,
includeLiveDescendantSummary: true,
limit: INBOX_ISSUE_LIST_LIMIT,
}).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId,
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
usePublishSharedQueryData(sharedInboxIssues, issues, issuesUpdatedAt);
const {
data: mineIssuesRaw = [],
isLoading: isMineIssuesLoading,
error: mineIssuesError,
dataUpdatedAt: mineIssuesUpdatedAt,
} = useQuery({
queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const,
queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const,
queryFn: () =>
issuesApi.listCompact(selectedCompanyId!, {
touchedByUserId: "me",
inboxArchivedByUserId: "me",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
projection: "index",
sortField: "updated",
sortDir: "desc",
includeRoutineExecutions: true,
includeLiveDescendantSummary: true,
limit: INBOX_ISSUE_LIST_LIMIT,
}).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && tab === "mine",
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const;
const mineIssuesQueryKey = [...queryKeys.issues.listMineByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const;
const sharedMineIssues = useSharedPollingQuery<Issue[]>({
companyId: selectedCompanyId,
resourceKey: "inbox:mine-issues",
queryKey: mineIssuesQueryKey,
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && tab === "mine",
});
usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt);
const {
data: touchedIssuesRaw = [],
isLoading: isTouchedIssuesLoading,
error: touchedIssuesError,
dataUpdatedAt: touchedIssuesUpdatedAt,
} = useQuery({
queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const,
queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const,
queryFn: () =>
issuesApi.listCompact(selectedCompanyId!, {
touchedByUserId: "me",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
projection: "index",
sortField: "updated",
sortDir: "desc",
includeRoutineExecutions: true,
includeLiveDescendantSummary: true,
limit: INBOX_ISSUE_LIST_LIMIT,
}).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && (tab !== "mine" && tab !== "blocked" || normalizedSearchQuery.length > 0),
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "compact", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const;
const touchedIssuesQueryKey = [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "index", "with-routine-executions", "live-descendant-summary", INBOX_ISSUE_LIST_LIMIT] as const;
const sharedTouchedIssues = useSharedPollingQuery<Issue[]>({
companyId: selectedCompanyId,
resourceKey: "inbox:touched-issues",
queryKey: touchedIssuesQueryKey,
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && (tab !== "mine" && tab !== "blocked" || normalizedSearchQuery.length > 0),
});
usePublishSharedQueryData(sharedTouchedIssues, touchedIssuesRaw, touchedIssuesUpdatedAt);
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true }),
const { data: heartbeatRuns, isLoading: isRunsLoading, error: runsError } = useQuery({
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "inbox-failures", INBOX_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true, latestFailuresOnly: true }),
enabled: !!selectedCompanyId,
refetchOnWindowFocus: false,
staleTime: INBOX_HOT_PATH_STALE_MS,
});
const contextIssueIds = useMemo(() => [...new Set([
...(tab === "mine" ? mineIssuesRaw : touchedIssuesRaw).map((issue) => issue.parentId),
...(heartbeatRuns ?? []).map(readIssueIdFromRun),
].filter((id): id is string => Boolean(id)))].sort(), [tab, mineIssuesRaw, touchedIssuesRaw, heartbeatRuns]);
const { data: contextIssues = [], isLoading: isContextIssuesLoading, error: contextIssuesError } = useQuery<Issue[]>({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "row-context", contextIssueIds],
placeholderData: (previous) => previous?.filter((issue) => issue.companyId === selectedCompanyId),
queryFn: async ({ signal }) => {
const result: Issue[] = [];
for (let offset = 0; offset < contextIssueIds.length; offset += 100) {
result.push(...await issuesApi.listCompact(selectedCompanyId!, {
ids: contextIssueIds.slice(offset, offset + 100), projection: "rows", limit: 100,
includeRoutineExecutions: true,
}, { signal }) as Issue[]);
}
return result;
},
enabled: !!selectedCompanyId && contextIssueIds.length > 0 && tab !== "blocked",
});
const issues = useMemo(() => [...mineIssuesRaw, ...touchedIssuesRaw, ...contextIssues], [mineIssuesRaw, touchedIssuesRaw, contextIssues]);
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
@@ -1077,14 +1088,14 @@ function StreamlinedInbox({
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
const { data: liveRuns, isLoading: isLiveRunsLoading, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedLiveRuns.enabled,
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const { data: companyMembers } = useQuery({
const { data: companyMembers, isLoading: isMembersLoading } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
@@ -1119,6 +1130,22 @@ function StreamlinedInbox({
() => getRecentTouchedIssues(touchedIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)),
[locallyArchivedIssueIds, touchedIssuesRaw],
);
const sourceSearchIds = useMemo(() => [...new Set([...mineIssuesRaw, ...touchedIssuesRaw].map((issue) => issue.id))].sort(), [mineIssuesRaw, touchedIssuesRaw]);
const { data: sourceSearchMatches = [], isLoading: isSourceSearchLoading, error: sourceSearchError } = useQuery<Issue[]>({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "inbox-source-search", normalizedSearchQuery, sourceSearchIds],
queryFn: async ({ signal }) => {
const matches: Issue[] = [];
for (let offset = 0; offset < sourceSearchIds.length; offset += 100) {
matches.push(...await issuesApi.listCompact(selectedCompanyId!, {
ids: sourceSearchIds.slice(offset, offset + 100), q: normalizedSearchQuery,
projection: "index", includeRoutineExecutions: true, limit: 100,
}, { signal }) as Issue[]);
}
return matches;
},
enabled: !!selectedCompanyId && normalizedSearchQuery.length > 0 && sourceSearchIds.length > 0,
});
const sourceSearchMatchIds = useMemo(() => new Set(sourceSearchMatches.map((issue) => issue.id)), [sourceSearchMatches]);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
@@ -1161,6 +1188,8 @@ function StreamlinedInbox({
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
error: externalObjectSummariesError,
refetch: retryExternalObjectSummaries,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
inboxIssueIdsForExternalObjectSummaries,
@@ -1403,6 +1432,7 @@ function StreamlinedInbox({
return workItemsToRender.filter((item) => {
if (item.kind === "issue") {
return matchesInboxIssueSearch(item.issue, q, {
matchedIssueIds: sourceSearchMatchIds,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
@@ -1446,6 +1476,7 @@ function StreamlinedInbox({
issueById,
isolatedWorkspacesEnabled,
normalizedSearchQuery,
sourceSearchMatchIds,
projectWorkspaceById,
]);
@@ -1456,6 +1487,7 @@ function StreamlinedInbox({
visibleIssues: visibleMineIssues,
searchableIssues: visibleTouchedIssues,
query: normalizedSearchQuery,
matchedIssueIds: sourceSearchMatchIds,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
@@ -1467,6 +1499,7 @@ function StreamlinedInbox({
executionWorkspaceById,
isolatedWorkspacesEnabled,
normalizedSearchQuery,
sourceSearchMatchIds,
projectWorkspaceById,
tab,
visibleMineIssues,
@@ -2391,10 +2424,6 @@ function StreamlinedInbox({
if (row) row.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message={`Select an organization to view ${surfaceLabel.toLowerCase()}.`} />;
}
const hasRunFailures = failedRuns.length > 0;
const showCompanyAlerts = shouldShowCompanyAlerts(tab) && showAlertsCategory;
const showAggregateAgentError =
@@ -2425,16 +2454,46 @@ function StreamlinedInbox({
showWorkItemsSection ? "work_items" : null,
].filter((key): key is SectionKey => key !== null);
const allLoaded =
const initialSourcesSettled =
!isSourceSearchLoading && !isSessionLoading && !isSettingsLoading && !isDismissalsLoading && !isAgentsLoading && !isProjectsLoading && !isMembersLoading && !isWorkspacesLoading &&
!isJoinRequestsLoading &&
!isApprovalsLoading &&
!isDashboardLoading &&
!isIssuesLoading &&
!isContextIssuesLoading &&
!isMineIssuesLoading &&
!isTouchedIssuesLoading &&
!isRunsLoading;
!isRunsLoading
&& (!issueFilters.liveOnly || !isLiveRunsLoading)
&& (issueFilters.externalObjectStatuses.length === 0 || externalObjectSummariesReady || !!externalObjectSummariesError);
const listError = sourceSearchError ?? (tab === "mine" ? mineIssuesError : touchedIssuesError)
?? (issueFilters.externalObjectStatuses.length > 0 ? externalObjectSummariesError : null);
const optionalSourceError = dismissalsError ?? approvalsError ?? joinRequestsError ?? runsError ?? contextIssuesError;
const retryFailedSources = () => {
// Retry only failed reads for this company; settled rows stay visible.
void queryClient.refetchQueries({ predicate: (query) => query.state.status === "error"
&& query.queryKey.includes(selectedCompanyId), type: "active" });
};
const revealScope = JSON.stringify([selectedCompanyId, tab, normalizedSearchQuery, issueFilters]);
const [revealedScope, setRevealedScope] = useState<string | null>(null);
useEffect(() => {
if (initialSourcesSettled && !listError) setRevealedScope(revealScope);
}, [initialSourcesSettled, listError, revealScope]);
const allLoaded = initialSourcesSettled || revealedScope === revealScope;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails(
selectedCompanyId, listRef,
groupedSections.map((group) => group.key + ":" + group.displayItems.map(getInboxWorkItemKey).join(",") + ":" + [...group.childrenByIssueId.values()].flat().map((issue) => issue.id).join(",")).join("|")
+ ":" + [...collapsedInboxParents].join(",") + ":" + [...collapsedGroupKeys].join(","),
allLoaded && !listError && tab !== "blocked",
issues,
);
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message={`Select an organization to view ${surfaceLabel.toLowerCase()}.`} />;
}
const markAllReadIssues = (tab === "mine" ? visibleMineIssues : unreadTouchedIssues)
.filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
const unreadIssueIds = markAllReadIssues
@@ -2459,6 +2518,12 @@ function StreamlinedInbox({
: null;
return (
<div className="space-y-6">
<IssueRowDetailsNotice
error={optionalSourceError ?? rowDetailsError}
message={optionalSourceError ? "Some inbox items couldn’t load. Showing available items." : undefined}
retryLabel={optionalSourceError ? "Retry missing items" : undefined}
onRetry={optionalSourceError ? retryFailedSources : () => void retryRowDetails()}
/>
<InboxCollectionToolbar
streamlined={streamlinedUiEnabled}
ariaLabel={`${surfaceLabel} controls`}
@@ -2744,7 +2809,12 @@ function StreamlinedInbox({
) : null}
/>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{listError && (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<p>Unable to load the inbox: {listError.message}</p>
<Button variant="outline" size="sm" onClick={() => { retryFailedSources(); if (externalObjectSummariesError) retryExternalObjectSummaries(); }}>Retry</Button>
</div>
)}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{tab === "blocked" ? (
@@ -2770,11 +2840,11 @@ function StreamlinedInbox({
</div>
) : null}
{tab !== "blocked" && !allLoaded && visibleSections.length === 0 && (
<PageSkeleton variant="inbox" />
{tab !== "blocked" && !allLoaded && !listError && (
<PageSkeleton variant="inbox" collectionHeading={groupBy !== "none" ? "group" : filterPreferences.showDateGroupSeparators ? "date" : null} />
)}
{tab !== "blocked" && allLoaded && visibleSections.length === 0 && (
{tab !== "blocked" && allLoaded && !listError && !optionalSourceError && visibleSections.length === 0 && (
<EmptyState
icon={searchQuery.trim() ? Search : InboxIcon}
message={
@@ -2791,7 +2861,7 @@ function StreamlinedInbox({
/>
)}
{tab !== "blocked" && showWorkItemsSection && (
{tab !== "blocked" && allLoaded && !listError && showWorkItemsSection && (
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
@@ -2821,6 +2891,7 @@ function StreamlinedInbox({
collapseParentId?: string | null;
allowArchive?: boolean;
}) => {
issue = enrichIssueRow(issue);
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
const isArchiving = archivingIssueIds.has(issue.id);
@@ -2853,6 +2924,7 @@ function StreamlinedInbox({
key={`issue:${issue.id}`}
issue={issue}
presentation={streamlinedUiEnabled ? "task" : "legacy"}
stableLayout
issueLinkState={issueLinkState}
treeGuides={depth}
chevronInGuide={streamlinedUiEnabled && depth > 0 && hasChildren}
@@ -3323,7 +3395,7 @@ function StreamlinedInbox({
</>
)}
{showAlertsSection && (
{allLoaded && !listError && showAlertsSection && (
<>
{showSeparatorBefore("alerts") && <Separator />}
<div>
+2 -1
View File
@@ -257,13 +257,14 @@ function OrganizationIssues({
participantAgentId ?? "__all__",
"workspace",
workspaceIdFilter ?? "__all__",
"compact",
streamlinedUiEnabled ? "index" : "compact",
"with-routine-executions",
showAgentCreatedTasks,
"infinite",
issuePageSize,
],
queryFn: ({ pageParam, signal }) => issuesApi.listCompact(selectedCompanyId!, {
projection: streamlinedUiEnabled ? "index" : undefined,
participantAgentId,
workspaceId: workspaceIdFilter,
includeRoutineExecutions: true,