Files
PaperClipAI/ui/src/lib/inbox.ts
T
ad961227f5 feat(secrets): add user-specific runtime secrets (#8825)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs often need provider credentials, API tokens, and other
environment-bound secrets.
> - Company-level secrets work for shared credentials, but they do not
model values that should differ by human operator.
> - Without a user-scoped model, a run can dispatch without knowing
whether the responsible human has supplied the needed value.
> - Paperclip also needs run attribution to make those user-scoped
runtime checks deterministic and auditable.
> - This pull request adds user-specific secret definitions, per-user
values, environment bindings, responsible-user attribution, and runtime
resolution gates.
> - The benefit is that teams can define the secret once, let each user
provide their own value, and block runs before dispatch when required
user secrets or active definitions are unavailable.

## Linked Issues or Issue Description

Refs #224
Refs #6057

This PR implements user-specific secret support as a core
secret-management capability rather than a one-off adapter setting. It
is related to existing public work on company secrets UI and runtime
secret refs, but is distinct because the value is owned by the
responsible user and resolved at run dispatch time.

Related PR search before opening found existing secrets work such as
#1550, #8256, #8614, #8634, and #8647; none of those add the full
user-secret definition/value/runtime gate covered here.

## What Changed

- Added user-secret definitions and per-user "My secrets" values,
keeping stored values out of access metadata.
- Added `user_secret_ref` environment bindings and UI affordances to
pick them alongside existing secret refs.
- Added responsible-user runtime resolution so user-secret refs resolve
against the human responsible for the run.
- Added pre-dispatch missing-secret gates so runs fail before adapter
dispatch when required user values are absent or definitions are
inactive.
- Added low-trust allowlist hardening for user-secret runtime access.
- Added issue, routine, run, and agent API key responsible-user
attribution and fail-closed dispatch behavior when attribution cannot be
resolved.
- Added denial-copy mapping so responsible-user authorization failures
surface as actionable run outcomes instead of opaque setup failures.
- Added OpenAPI documentation for the user-secret routes.
- Rebases cleanly on current `master`; migrations were renumbered
incrementally as `0128_user_specific_secrets`,
`0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant` after upstream `0126`/`0127`
migrations.
- Removed previously committed local design screenshots so the PR
contains code/docs/tests only.

## Verification

- PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on
`paperclipai/paperclip:master`.
- PASS: `git diff --check`
- PASS: `git diff --name-only public/master...HEAD | rg
'^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true`
produced no files.
- PASS: migration journal audit confirmed unique indexes through `130`
with tail entries `0126_issue_comment_derived_attribution`,
`0127_environment_custom_images_instance_scoped`,
`0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant`.
- PASS: `pnpm --filter @paperclipai/ui typecheck`
- PASS: `pnpm --filter @paperclipai/server typecheck`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-active-run-output-watchdog.test.ts
src/__tests__/heartbeat-stale-queue-invalidation.test.ts
src/__tests__/heartbeat-workspace-finalize-branch.test.ts
src/__tests__/issue-monitor-scheduler.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-comment-wake-batching.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
src/__tests__/heartbeat-plugin-environment.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/low-trust-red-team-routes.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/secrets-service.test.ts` (55 tests)
- PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` (89 tests after final
Greptile cleanup fixes)
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests
after the final rebase CI fix)
- PASS: focused server Vitest batches covering heartbeat recovery,
project env, plugin env, routines, low-trust, pipelines, monitors,
watchdog, and stale queue paths.
- PASS: GitHub checks are green on
`2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck +
Release Registry, Build, General tests, serialized server suites, e2e,
Canary Dry Run, verify, security checks, and Greptile Review.
- PASS: Greptile Review completed successfully on
`2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5,
and GraphQL review-thread audit returned zero unresolved non-outdated
threads.

## Risks

- Runtime behavior now depends on a run having a correct responsible
user; missing or incorrect responsibility assignment can block runs
before adapter dispatch.
- `user_secret_ref` bindings intentionally expose metadata without
values, but UI/API callers may need to handle the new binding kind
explicitly.
- External secret providers and IAM policies are not automatically
provisioned by this PR; operators still need to configure provider-side
access for non-local vaults.
- The PR is broad across db/shared/server/UI/runtime paths, so release
validation should include both API and UI secret workflows before merge.
- The migration renumbering is intentionally incremental after upstream
migrations; the branch migrations use guarded
column/table/index/constraint creation so users who tested the older
draft numbering should not hit duplicate DDL for the existing objects.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent (`gpt-5`), Codex local adapter
with shell/tool use and code execution. Context window and internal
reasoning mode are not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 05:58:20 -05:00

1280 lines
39 KiB
TypeScript

import type {
Approval,
DashboardSummary,
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 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";
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 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 InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
mineIssues: number;
alerts: number;
}
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}`;
}
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),
};
} 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),
}),
);
} 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 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 shouldIncludeRoutineExecutionIssue(
issue: Pick<Issue, "originKind">,
hideRoutineExecutions: boolean,
): boolean {
return !hideRoutineExecutions || issue.originKind !== "routine_execution";
}
export function filterInboxIssues(issues: Issue[], hideRoutineExecutions: boolean): Issue[] {
if (!hideRoutineExecutions) return issues;
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, hideRoutineExecutions));
}
export function matchesInboxIssueSearch(
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
query: string,
{
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: InboxWorkspaceGroupingOptions & {
isolatedWorkspacesEnabled?: boolean;
} = {},
): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) 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,
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
visibleIssues: Issue[];
searchableIssues: Issue[];
query: 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, {
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}),
)
.sort(sortIssuesByMostRecentActivity);
}
export function getInboxSearchSupplementIssues({
query,
filteredWorkItems,
archivedSearchIssues,
remoteIssues,
issueFilters,
currentUserId,
enableRoutineVisibilityFilter = false,
liveIssueIds,
issueFilterContext = {},
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
archivedSearchIssues: Issue[];
remoteIssues: Issue[];
issueFilters: IssueFilterState;
currentUserId?: string | null;
enableRoutineVisibilityFilter?: 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,
enableRoutineVisibilityFilter,
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 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 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;
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;
}
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 failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
).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 &&
failedRuns === 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,
};
}