Files
PaperClipAI/ui/src/lib/inbox.ts
T
DottaandPaperclip 5061ab7b35 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>
2026-10-26 14:29:20 -05:00

1241 lines
38 KiB
TypeScript

import type {
Approval,
HeartbeatRun,
InboxDismissal,
Issue,
JoinRequest,
} from "@paperclipai/shared";
import {
applyIssueFilters,
defaultIssueFilterState,
normalizeIssueFilterState,
type IssueFilterState,
type IssueFilterWorkspaceContext,
} from "./issue-filters";
import { formatAssigneeUserLabel } from "./assignees";
export const RECENT_ISSUES_LIMIT = 100;
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";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
export const INBOX_COLLAPSED_PARENTS_KEY_PREFIX = "paperclip:inbox:collapsed-parents";
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
export type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type" | "assignee" | "project" | "workspace";
export const inboxIssueColumns = [
"status",
"id",
"assignee",
"kickedOffBy",
"project",
"workspace",
"parent",
"labels",
"updated",
] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
export interface InboxFilterPreferences {
allCategoryFilter: InboxCategoryFilter;
allApprovalFilter: InboxApprovalFilter;
issueFilters: IssueFilterState;
}
export type InboxWorkItem =
| {
kind: "issue";
timestamp: number;
issue: Issue;
}
| {
kind: "approval";
timestamp: number;
approval: Approval;
}
| {
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}
| {
kind: "join_request";
timestamp: number;
joinRequest: JoinRequest;
};
export interface InboxWorkItemGroup {
key: string;
label: string | null;
items: InboxWorkItem[];
}
export type InboxSearchSection = "none" | "archived" | "other";
export interface InboxGroupedSection {
key: string;
label: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
searchSection: InboxSearchSection;
}
export interface InboxKeyboardGroupSection {
key: string;
label?: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: ReadonlyMap<string, Issue[]>;
}
export type InboxKeyboardNavEntry =
| {
type: "group";
groupKey: string;
label: string;
collapsed: boolean;
}
| {
type: "top";
itemKey: string;
item: InboxWorkItem;
}
| {
type: "child";
issueId: string;
issue: Issue;
};
export interface InboxProjectWorkspaceLookup {
name: string;
projectId?: string | null;
}
export interface InboxExecutionWorkspaceLookup {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId?: string | null;
}
export interface InboxWorkspaceGroupingOptions {
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
projectById?: ReadonlyMap<string, { name: string | null | undefined }>;
agentById?: ReadonlyMap<string, string | null | undefined>;
userLabelById?: ReadonlyMap<string, string>;
currentUserId?: string | null;
}
export interface InboxIssueGroupCreateDefaults {
projectId?: string;
projectWorkspaceId?: string;
executionWorkspaceId?: string;
executionWorkspaceMode?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
}
const defaultInboxFilterPreferences: InboxFilterPreferences = {
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: defaultIssueFilterState,
};
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
return value === "issues_i_touched"
|| value === "join_requests"
|| value === "approvals"
|| value === "failed_runs"
|| value === "alerts"
? value
: "everything";
}
function normalizeInboxApprovalFilter(value: unknown): InboxApprovalFilter {
return value === "actionable" || value === "resolved" ? value : "all";
}
function getInboxFilterPreferencesStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
}
function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
}
function getInboxCollapsedParentsStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_COLLAPSED_PARENTS_KEY_PREFIX}:${companyId}`;
}
export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
issueFilters: {
...normalizeIssueFilterState(parsed.issueFilters),
workspaces: [],
},
};
} catch {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
}
export function saveInboxFilterPreferences(
companyId: string | null | undefined,
preferences: InboxFilterPreferences,
) {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(
storageKey,
JSON.stringify({
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
issueFilters: {
...normalizeIssueFilterState(preferences.issueFilters),
workspaces: [],
},
}),
);
} catch {
// Ignore localStorage failures.
}
}
export function loadCollapsedInboxGroupKeys(
companyId: string | null | undefined,
): Set<string> {
const storageKey = getInboxCollapsedGroupsStorageKey(companyId);
if (!storageKey) return new Set();
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
} catch {
return new Set();
}
}
export function saveCollapsedInboxGroupKeys(
companyId: string | null | undefined,
groupKeys: ReadonlySet<string>,
) {
const storageKey = getInboxCollapsedGroupsStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(storageKey, JSON.stringify([...groupKeys]));
} catch {
// Ignore localStorage failures.
}
}
export function loadCollapsedInboxParentIds(
companyId: string | null | undefined,
): Set<string> {
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
if (!storageKey) return new Set();
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
} catch {
return new Set();
}
}
export function saveCollapsedInboxParentIds(
companyId: string | null | undefined,
parentIds: ReadonlySet<string>,
) {
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(storageKey, JSON.stringify([...parentIds]));
} catch {
// Ignore localStorage failures.
}
}
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
} catch {
return new Set();
}
}
export function saveDismissedInboxAlerts(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
);
}
export function isInboxEntityDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: string | Date | null | undefined,
): boolean {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveReadInboxItems(ids: Set<string>) {
try {
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function normalizeInboxIssueColumns(columns: Iterable<string | InboxIssueColumn>): InboxIssueColumn[] {
const selected = new Set(columns);
return inboxIssueColumns.filter((column) => selected.has(column));
}
export function getAvailableInboxIssueColumns(enableWorkspaceColumn: boolean): InboxIssueColumn[] {
if (enableWorkspaceColumn) return [...inboxIssueColumns];
return inboxIssueColumns.filter((column) => column !== "workspace");
}
export function loadInboxIssueColumns(): InboxIssueColumn[] {
try {
const raw = localStorage.getItem(INBOX_ISSUE_COLUMNS_KEY);
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
return normalizeInboxIssueColumns(parsed);
} catch {
return DEFAULT_INBOX_ISSUE_COLUMNS;
}
}
export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
try {
localStorage.setItem(
INBOX_ISSUE_COLUMNS_KEY,
JSON.stringify(normalizeInboxIssueColumns(columns)),
);
} catch {
// Ignore localStorage failures.
}
}
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
try {
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
return raw === "type" || raw === "assignee" || raw === "project" || raw === "workspace" ? raw : "none";
} catch {
return "none";
}
}
export function saveInboxWorkItemGroupBy(groupBy: InboxWorkItemGroupBy) {
try {
localStorage.setItem(INBOX_GROUP_BY_KEY, groupBy);
} catch {
// Ignore localStorage failures.
}
}
export function shouldResetInboxWorkspaceGrouping(
groupBy: InboxWorkItemGroupBy,
isolatedWorkspacesEnabled: boolean,
experimentalSettingsLoaded: boolean,
): boolean {
return experimentalSettingsLoaded && groupBy === "workspace" && !isolatedWorkspacesEnabled;
}
export function matchesInboxIssueSearch(
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;
if (!isolatedWorkspacesEnabled) return false;
const workspaceName = resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
return workspaceName?.toLowerCase().includes(normalizedQuery) ?? false;
}
export function getArchivedInboxSearchIssues({
visibleIssues,
searchableIssues,
query,
matchedIssueIds,
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
visibleIssues: Issue[];
searchableIssues: Issue[];
query: string;
matchedIssueIds?: ReadonlySet<string>;
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id));
return searchableIssues
.filter((issue) => !visibleIssueIds.has(issue.id))
.filter((issue) =>
matchesInboxIssueSearch(issue, normalizedQuery, {
matchedIssueIds,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}),
)
.sort(sortIssuesByMostRecentActivity);
}
export function getInboxSearchSupplementIssues({
query,
filteredWorkItems,
archivedSearchIssues,
remoteIssues,
issueFilters,
currentUserId,
enableAgentCreatedVisibilityFilter = false,
liveIssueIds,
issueFilterContext = {},
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
archivedSearchIssues: Issue[];
remoteIssues: Issue[];
issueFilters: IssueFilterState;
currentUserId?: string | null;
enableAgentCreatedVisibilityFilter?: boolean;
liveIssueIds?: ReadonlySet<string>;
issueFilterContext?: IssueFilterWorkspaceContext;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
const visibleIssueIds = new Set([
...filteredWorkItems
.filter((item): item is Extract<InboxWorkItem, { kind: "issue" }> => item.kind === "issue")
.map((item) => item.issue.id),
...archivedSearchIssues.map((issue) => issue.id),
]);
return applyIssueFilters(
remoteIssues,
issueFilters,
currentUserId,
enableAgentCreatedVisibilityFilter,
liveIssueIds,
issueFilterContext,
)
.filter((issue) => !visibleIssueIds.has(issue.id));
}
function formatDefaultWorkspaceGroupLabel(name: string | null | undefined): string {
const normalizedName = name?.trim();
return normalizedName ? `${normalizedName} (default)` : "Default workspace";
}
function resolveDefaultProjectWorkspaceInfo(
issue: Pick<Issue, "projectId">,
{
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: Pick<InboxWorkspaceGroupingOptions, "projectWorkspaceById" | "defaultProjectWorkspaceIdByProjectId">,
): { id: string; label: string } | null {
if (!issue.projectId) return null;
const defaultProjectWorkspaceId = defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null;
if (!defaultProjectWorkspaceId) return null;
return {
id: defaultProjectWorkspaceId,
label: formatDefaultWorkspaceGroupLabel(projectWorkspaceById?.get(defaultProjectWorkspaceId)?.name),
};
}
export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: InboxWorkspaceGroupingOptions,
): string | null {
const defaultProjectWorkspaceId = issue.projectId
? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
: null;
if (issue.executionWorkspaceId) {
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace" && linkedProjectWorkspaceId === defaultProjectWorkspaceId;
if (isDefaultSharedExecutionWorkspace) return null;
const workspaceName = executionWorkspace?.name;
if (workspaceName) return workspaceName;
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name;
if (workspaceName) return workspaceName;
}
return null;
}
export function resolveIssueWorkspaceGroup(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: InboxWorkspaceGroupingOptions = {},
): { key: string; label: string } {
const defaultProjectWorkspace = resolveDefaultProjectWorkspaceInfo(issue, {
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
if (issue.executionWorkspaceId) {
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace"
&& linkedProjectWorkspaceId != null
&& linkedProjectWorkspaceId === defaultProjectWorkspace?.id;
if (isDefaultSharedExecutionWorkspace && defaultProjectWorkspace) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
const workspaceName = executionWorkspace?.name?.trim();
if (workspaceName) {
return {
key: `workspace:execution:${issue.executionWorkspaceId}`,
label: workspaceName,
};
}
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspace?.id) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name?.trim();
if (workspaceName) {
return {
key: `workspace:project:${issue.projectWorkspaceId}`,
label: workspaceName,
};
}
}
if (defaultProjectWorkspace) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
return {
key: "workspace:none",
label: "No workspace",
};
}
export function loadInboxNesting(): boolean {
try {
const raw = localStorage.getItem(INBOX_NESTING_KEY);
return raw !== "false";
} catch {
return true;
}
}
export function saveInboxNesting(enabled: boolean) {
try {
localStorage.setItem(INBOX_NESTING_KEY, String(enabled));
} catch {
// Ignore localStorage failures.
}
}
export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile: boolean): boolean {
return preferenceEnabled && !isMobile;
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (
raw === "all"
|| raw === "unread"
|| raw === "recent"
|| raw === "mine"
|| raw === "blocked"
) return raw;
if (raw === "new") return "mine";
return "mine";
} catch {
return "mine";
}
}
export function saveLastInboxTab(tab: InboxTab) {
try {
localStorage.setItem(INBOX_LAST_TAB_KEY, tab);
} catch {
// Ignore localStorage failures.
}
}
export function isMineInboxTab(tab: InboxTab): boolean {
return tab === "mine";
}
export function shouldShowCompanyAlerts(tab: InboxTab): boolean {
return tab === "all";
}
export function resolveInboxSelectionIndex(
previousIndex: number,
itemCount: number,
): number {
if (itemCount === 0) return -1;
if (previousIndex < 0) return -1;
return Math.min(previousIndex, itemCount - 1);
}
export function getInboxKeyboardSelectionIndex(
previousIndex: number,
itemCount: number,
direction: "next" | "previous",
): number {
if (itemCount === 0) return -1;
if (previousIndex < 0) return 0;
return direction === "next"
? Math.min(previousIndex + 1, itemCount - 1)
: Math.max(previousIndex - 1, 0);
}
export { latestFailedRunsByAgent as getLatestFailedRunsByAgent } from "@paperclipai/shared";
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastActivityAt = normalizeTimestamp(issue.lastActivityAt);
if (lastActivityAt > 0) return lastActivityAt;
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
return normalizeTimestamp(issue.updatedAt);
}
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
}
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
}
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function getApprovalsForTab(
approvals: Approval[],
tab: InboxTab,
filter: InboxApprovalFilter,
currentUserId?: string | null,
): Approval[] {
const sortedApprovals = [...approvals].sort(
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
);
if (tab === "mine") {
return sortedApprovals.filter((approval) => isApprovalVisibleInMine(approval, currentUserId));
}
if (tab === "recent") return sortedApprovals;
if (tab === "unread") {
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
}
if (filter === "all") return sortedApprovals;
return sortedApprovals.filter((approval) => {
const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return filter === "actionable" ? isActionable : !isActionable;
});
}
export function isApprovalVisibleInMine(
approval: Approval,
currentUserId?: string | null,
): boolean {
if (ACTIONABLE_APPROVAL_STATUSES.has(approval.status)) return true;
if (!currentUserId) return false;
return approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId;
}
export function approvalActivityTimestamp(approval: Approval): number {
const updatedAt = normalizeTimestamp(approval.updatedAt);
if (updatedAt > 0) return updatedAt;
return normalizeTimestamp(approval.createdAt);
}
export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
joinRequests = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
joinRequests?: JoinRequest[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
kind: "issue" as const,
timestamp: issueLastActivityTimestamp(issue),
issue,
})),
...approvals.map((approval) => ({
kind: "approval" as const,
timestamp: approvalActivityTimestamp(approval),
approval,
})),
...failedRuns.map((run) => ({
kind: "failed_run" as const,
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
...joinRequests.map((joinRequest) => ({
kind: "join_request" as const,
timestamp: normalizeTimestamp(joinRequest.createdAt),
joinRequest,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
if (a.kind === "approval" && b.kind === "approval") {
return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval);
}
return a.kind === "approval" ? -1 : 1;
});
}
const inboxWorkItemKindOrder: InboxWorkItem["kind"][] = [
"issue",
"approval",
"failed_run",
"join_request",
];
const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
issue: "Tasks",
approval: "Approvals",
failed_run: "Failed runs",
join_request: "Join requests",
};
function resolveIssueAssigneeGroup(
issue: Pick<Issue, "assigneeAgentId" | "assigneeUserId">,
{
agentById,
currentUserId,
userLabelById,
}: Pick<InboxWorkspaceGroupingOptions, "agentById" | "currentUserId" | "userLabelById">,
): { key: string; label: string } {
if (issue.assigneeAgentId) {
const agentName = agentById?.get(issue.assigneeAgentId)?.trim();
return {
key: `assignee:agent:${issue.assigneeAgentId}`,
label: agentName || issue.assigneeAgentId.slice(0, 8),
};
}
if (issue.assigneeUserId) {
return {
key: `assignee:user:${issue.assigneeUserId}`,
label: formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, userLabelById) ?? "User",
};
}
return { key: "assignee:none", label: "Unassigned" };
}
function resolveIssueProjectGroup(
issue: Pick<Issue, "projectId">,
{ projectById }: Pick<InboxWorkspaceGroupingOptions, "projectById">,
): { key: string; label: string } {
if (!issue.projectId) return { key: "project:none", label: "No project" };
const projectName = projectById?.get(issue.projectId)?.name?.trim();
return {
key: `project:${issue.projectId}`,
label: projectName || issue.projectId.slice(0, 8),
};
}
function groupInboxWorkItemsByIssueGroup(
items: InboxWorkItem[],
resolveIssueGroup: (issue: Issue) => { key: string; label: string },
): InboxWorkItemGroup[] {
const groups = new Map<string, { label: string; items: InboxWorkItem[]; latestTimestamp: number }>();
for (const item of items) {
const resolvedGroup = item.kind === "issue"
? resolveIssueGroup(item.issue)
: { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] };
const existing = groups.get(resolvedGroup.key);
if (existing) {
existing.items.push(item);
existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp);
} else {
groups.set(resolvedGroup.key, {
label: resolvedGroup.label,
items: [item],
latestTimestamp: item.timestamp,
});
}
}
return [...groups.entries()]
.map(([key, value]) => ({
key,
label: value.label,
items: value.items,
latestTimestamp: value.latestTimestamp,
}))
.sort((a, b) => {
const timestampDiff = b.latestTimestamp - a.latestTimestamp;
if (timestampDiff !== 0) return timestampDiff;
return a.label.localeCompare(b.label);
})
.map(({ key, label, items: groupItems }) => ({
key,
label,
items: groupItems,
}));
}
export function groupInboxWorkItems(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
options: InboxWorkspaceGroupingOptions = {},
): InboxWorkItemGroup[] {
if (groupBy === "none") {
return [{ key: "__all", label: null, items }];
}
if (groupBy === "workspace") {
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueWorkspaceGroup(issue, options));
}
if (groupBy === "assignee") {
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueAssigneeGroup(issue, options));
}
if (groupBy === "project") {
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueProjectGroup(issue, options));
}
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
for (const item of items) {
const existing = groups.get(item.kind) ?? [];
existing.push(item);
groups.set(item.kind, existing);
}
const orderedGroups: InboxWorkItemGroup[] = [];
for (const kind of inboxWorkItemKindOrder) {
const groupItems = groups.get(kind) ?? [];
if (groupItems.length === 0) continue;
orderedGroups.push({
key: kind,
label: inboxWorkItemKindLabels[kind],
items: groupItems,
});
}
return orderedGroups;
}
function stripInboxSearchGroupPrefix(groupKey: string) {
return groupKey
.replace(/^archived-search:/, "")
.replace(/^other-search:/, "");
}
function firstIssueFromInboxWorkItems(items: InboxWorkItem[]): Issue | null {
return items.find((item): item is InboxWorkItem & { kind: "issue" } => item.kind === "issue")?.issue ?? null;
}
function projectIdForProjectWorkspace(
projectWorkspaceId: string | null | undefined,
options: InboxWorkspaceGroupingOptions,
fallbackIssue: Issue | null,
) {
if (!projectWorkspaceId) return fallbackIssue?.projectId ?? null;
return options.projectWorkspaceById?.get(projectWorkspaceId)?.projectId
?? (fallbackIssue?.projectWorkspaceId === projectWorkspaceId ? fallbackIssue.projectId : null);
}
export function buildInboxIssueGroupCreateDefaults(
groupKey: string,
groupBy: InboxWorkItemGroupBy,
items: InboxWorkItem[],
options: InboxWorkspaceGroupingOptions = {},
): InboxIssueGroupCreateDefaults | null {
const fallbackIssue = firstIssueFromInboxWorkItems(items);
if (!fallbackIssue) return null;
const key = stripInboxSearchGroupPrefix(groupKey);
if (groupBy === "project") {
if (!key.startsWith("project:")) return {};
const projectId = key.slice("project:".length);
return projectId && projectId !== "none" ? { projectId } : {};
}
if (groupBy === "assignee") {
if (key.startsWith("assignee:agent:")) {
const assigneeAgentId = key.slice("assignee:agent:".length);
return assigneeAgentId ? { assigneeAgentId } : {};
}
if (key.startsWith("assignee:user:")) {
const assigneeUserId = key.slice("assignee:user:".length);
return assigneeUserId ? { assigneeUserId } : {};
}
return {};
}
if (groupBy === "workspace") {
if (key.startsWith("workspace:execution:")) {
const executionWorkspaceId = key.slice("workspace:execution:".length);
if (!executionWorkspaceId) return {};
const executionWorkspace = options.executionWorkspaceById?.get(executionWorkspaceId) ?? null;
const projectWorkspaceId = executionWorkspace?.projectWorkspaceId
?? (fallbackIssue.executionWorkspaceId === executionWorkspaceId ? fallbackIssue.projectWorkspaceId : null);
const projectId = executionWorkspace?.projectId
?? projectIdForProjectWorkspace(projectWorkspaceId, options, fallbackIssue);
return {
executionWorkspaceId,
executionWorkspaceMode: "reuse_existing",
...(projectId ? { projectId } : {}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
};
}
if (key.startsWith("workspace:project:")) {
const projectWorkspaceId = key.slice("workspace:project:".length);
if (!projectWorkspaceId) return {};
const projectId = projectIdForProjectWorkspace(projectWorkspaceId, options, fallbackIssue);
return {
...(projectId ? { projectId } : {}),
projectWorkspaceId,
};
}
}
return {};
}
/**
* Groups parent-child issues in a flat InboxWorkItem list.
*
* - Children whose parent is also in the list are removed from the top level
* and stored in `childrenByIssueId`.
* - The parent's sort timestamp becomes max(parent, children) so that a group
* with a recently-updated child floats to the top.
* - If a parent is absent (e.g. archived), children remain as independent roots.
*/
export function buildInboxNesting(items: InboxWorkItem[]): {
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
} {
const issueItems: (InboxWorkItem & { kind: "issue" })[] = [];
const nonIssueItems: InboxWorkItem[] = [];
for (const item of items) {
if (item.kind === "issue") issueItems.push(item as InboxWorkItem & { kind: "issue" });
else nonIssueItems.push(item);
}
const issueIdSet = new Set(issueItems.map((i) => i.issue.id));
const childrenByIssueId = new Map<string, Issue[]>();
const childIds = new Set<string>();
for (const item of issueItems) {
const { issue } = item;
if (issue.parentId && issueIdSet.has(issue.parentId)) {
childIds.add(issue.id);
const arr = childrenByIssueId.get(issue.parentId) ?? [];
arr.push(issue);
childrenByIssueId.set(issue.parentId, arr);
}
}
const subtreeActivityTimestamp = (issue: Issue, seen: ReadonlySet<string> = new Set()): number => {
const ownTimestamp = issueLastActivityTimestamp(issue);
if (seen.has(issue.id)) return ownTimestamp;
const nextSeen = new Set(seen);
nextSeen.add(issue.id);
const children = childrenByIssueId.get(issue.id) ?? [];
if (children.length === 0) return ownTimestamp;
return Math.max(
ownTimestamp,
...children.map((child) => subtreeActivityTimestamp(child, nextSeen)),
);
};
// Sort each child list by most recent descendant activity, not just direct issue activity.
for (const children of childrenByIssueId.values()) {
children.sort((a, b) => {
const activityDiff = subtreeActivityTimestamp(b) - subtreeActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return sortIssuesByMostRecentActivity(a, b);
});
}
// Build root issue items with group-adjusted timestamps
const rootIssueItems: InboxWorkItem[] = issueItems
.filter((item) => !childIds.has(item.issue.id))
.map((item) => {
const children = childrenByIssueId.get(item.issue.id);
if (!children?.length) return item;
const maxChildTs = Math.max(...children.map((child) => subtreeActivityTimestamp(child)));
return { ...item, timestamp: Math.max(item.timestamp, maxChildTs) };
});
// Merge and re-sort
const displayItems = [...rootIssueItems, ...nonIssueItems].sort((a, b) => {
const diff = b.timestamp - a.timestamp;
if (diff !== 0) return diff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
return 0;
});
return { displayItems, childrenByIssueId };
}
export function buildGroupedInboxSections(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
workspaceGrouping: InboxWorkspaceGroupingOptions,
options?: { keyPrefix?: string; searchSection?: InboxSearchSection; nestingEnabled?: boolean },
): InboxGroupedSection[] {
const keyPrefix = options?.keyPrefix ?? "";
const searchSection = options?.searchSection ?? "none";
const nestingEnabled = options?.nestingEnabled ?? false;
if (searchSection !== "none" && items.length === 0) {
return [];
}
return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => {
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
? buildInboxNesting(group.items)
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
return {
key: `${keyPrefix}${group.key}`,
label: group.label,
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
searchSection,
};
});
}
export function getInboxWorkItemKey(item: InboxWorkItem): string {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "failed_run") return `run:${item.run.id}`;
return `join:${item.joinRequest.id}`;
}
export function buildInboxKeyboardNavEntries(
groupedSections: ReadonlyArray<InboxKeyboardGroupSection>,
collapsedGroupKeys: ReadonlySet<string>,
collapsedInboxParents: ReadonlySet<string>,
): InboxKeyboardNavEntry[] {
const entries: InboxKeyboardNavEntry[] = [];
for (const group of groupedSections) {
const isCollapsed = collapsedGroupKeys.has(group.key);
if (group.label) {
entries.push({
type: "group",
groupKey: group.key,
label: group.label,
collapsed: isCollapsed,
});
}
if (isCollapsed) continue;
const addIssueChildren = (issueId: string, seen: ReadonlySet<string>) => {
const children = group.childrenByIssueId.get(issueId);
if (!children?.length || collapsedInboxParents.has(issueId)) return;
for (const child of children) {
if (seen.has(child.id)) continue;
const nextSeen = new Set(seen);
nextSeen.add(child.id);
entries.push({
type: "child",
issueId: child.id,
issue: child,
});
addIssueChildren(child.id, nextSeen);
}
};
for (const item of group.displayItems) {
entries.push({
type: "top",
itemKey: `${group.key}:${getInboxWorkItemKey(item)}`,
item,
});
if (item.kind !== "issue") continue;
addIssueChildren(item.issue.id, new Set([item.issue.id]));
}
}
return entries;
}
export function shouldShowInboxSection({
tab,
hasItems,
showOnMine,
showOnRecent,
showOnUnread,
showOnAll,
}: {
tab: InboxTab;
hasItems: boolean;
showOnMine: boolean;
showOnRecent: boolean;
showOnUnread: boolean;
showOnAll: boolean;
}): boolean {
if (!hasItems) return false;
if (tab === "mine") return showOnMine;
if (tab === "recent") return showOnRecent;
if (tab === "unread") return showOnUnread;
return showOnAll;
}