Files
PaperClipAI/ui/src/lib/inbox.test.ts
T
DottaandPaperclip b31ec0a2dc feat(ui): link routines to a filtered task list (#18317)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Routines create tasks that people need to inspect and manage.
> - The routines list sends its View all runs link to Audit.
> - Tasks already supports routine origin filters in its API, but the
page has no control for them.
> - This pull request adds a Routine tasks checkbox and links to that
selection.
> - People can inspect routine work in the normal task list, search, and
board views.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The routines list link and the Tasks filters.

**Current behavior**

View all runs opens Audit. People cannot select routine-created tasks in
the Tasks filter menu.

**Proposed behavior**

View all tasks opens `/issues?view=all&originKind=routine_execution`.
Routine tasks is selected. People can toggle or clear it from Tasks.

**Reason and benefit**

People can use normal task controls to inspect work created by routines.
The filter includes manually started routine runs. It excludes manual
tasks and other automatic work.

**Breaking changes**

The routines list link changes its label and destination. No API,
schema, authorization, or routine execution contract changes.

## What Changed

- Add Routine tasks under Visibility in the current, streamlined, and
legacy Tasks filters. Keep Live runs only in the same group and use Show
agent-created tasks with proper capitalization where that control is
available.
- Keep the selection in the URL through reload, search, task navigation,
and checkbox changes.
- Apply the existing API origin filter before pagination. Include
routine tasks even when other automatic tasks are hidden.
- Keep source filtering in the shared task filter module and collection
owners. Reuse the existing company-scoped issue-list contract. Hide the
live-run control in the design sandbox, which does not load live-run
IDs.
- Update regression coverage, product docs, feature/access maps, and the
toolbar preview.

## Verification

- All 58 final-head checks are complete with no failures for
`f46c2e409b20fa372d166f4721601a0471894fb0`, including successful `ci /
verify` and `ci / e2e` gates. Greptile and Paperclip Review rate this
commit 5/5 with no actionable findings or unresolved review threads. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41078093110).
- Local checks pass: full repo typecheck/build for the feature, UI
typecheck/build after the final refinements, token gates, feature-map
checks, and Storybook catalog generation.
- Regression coverage exercises both real list implementations, filter
grouping and selection, search and all board columns, URL/pagination
state, and mixed pending-decision batches. The Visibility update passed
all 89 relevant tests across separate runs. The final sandbox correction
passes all 10 filter-menu tests, including three cases that reproduced
the bug before the fix. The earlier broad UI run passed 10,043 tests
across 845 files.
- Browser proof used the actual local app with disposable PostgreSQL
data: three tasks created through the routine-run API and one manual
task. Followed View all tasks; verified the selected Routine tasks
checkbox under Visibility, toggling, reload, search, and board view.
Checked current, streamlined, and legacy filter presentations.
Screenshots show the link, routine-only results, and requested
capitalization/grouping.
- Failure accounting: the initial broad UI run found two preference
expectations that needed the new false default; those were corrected
before the full UI pass. Scrolling tests hit five-second timeouts in
combined runs and passed in isolated reruns without timeout changes. A
duplicate full local UI run was stopped after the earlier final-commit
CI UI lanes passed, with no failures reported in that stopped run.
- Full local `pnpm test:run` could not complete because native
PostgreSQL initialization exhausted the host semaphore pool. A direct
initdb probe confirmed `could not create semaphores: No space left on
device`. Browser verification used a disposable container; no host
settings or other workspace processes were changed.
- Review found that the newly exposed live-run control could empty the
design sandbox, which has no live-run IDs. The sandbox now disables that
unsupported control; its regression test failed before the fix and
passes after it.

## Risks

- Low risk. The change uses the existing routine origin and read
permissions. A routine task remains automatic work, including when a
person starts it manually.
- The routine checkbox is exposed only on the Tasks collection. Other
filter consumers retain their controls and normalize the new field to
false.
- Routine histories and Audit run links inside routine details keep
their current behavior.

## Model Used

- OpenAI GPT-6, through Codex, assisted with implementation and
verification. The exact runtime model ID and context window are not
exposed in this session. Capabilities used: repository editing, command
execution, tests, and browser interaction.

## 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-30 10:28:58 -05:00

1468 lines
48 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it } from "vitest";
import type {
Approval,
ExecutionWorkspace,
ExternalObjectSummary,
HeartbeatRun,
Issue,
JoinRequest,
ProjectWorkspace,
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxIssueGroupCreateDefaults,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getInboxSearchSupplementIssues,
getRecentTouchedIssues,
getUnreadTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxWorkItemGroupBy,
loadCollapsedInboxGroupKeys,
loadCollapsedInboxParentIds,
loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveIssueWorkspaceGroup,
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
saveCollapsedInboxParentIds,
saveInboxIssueColumns,
saveInboxWorkItemGroupBy,
saveLastInboxTab,
shouldShowCompanyAlerts,
shouldResetInboxWorkspaceGrouping,
shouldShowInboxSection,
type InboxWorkItem,
} from "./inbox";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
},
configurable: true,
});
function makeApproval(status: Approval["status"]): Approval {
return {
id: `approval-${status}`,
companyId: "company-1",
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status,
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeApprovalWithTimestamps(
id: string,
status: Approval["status"],
updatedAt: string,
): Approval {
return {
...makeApproval(status),
id,
createdAt: new Date(updatedAt),
updatedAt: new Date(updatedAt),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestEmailSnapshot: null,
requestIp: "127.0.0.1",
requestingUserId: null,
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun {
return {
id,
companyId: "company-1",
agentId,
scopeKind: "company",
issueId: null,
responsibleUserId: "user-1",
invocationSource: "assignment",
triggerDetail: null,
status,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,
processGroupId: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
livenessState: null,
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: null,
nextAction: null,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date(createdAt),
finishedAt: null,
createdAt: new Date(createdAt),
updatedAt: new Date(createdAt),
};
}
function makeIssue(id: string, isUnreadForMe: boolean): Issue {
return {
id,
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
description: null,
status: "todo",
workMode: "standard",
priority: "medium",
reviewPolicy: null,
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: `PAP-${id}`,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
lastActivityAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe,
};
}
function makeProjectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
return {
id: "project-workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Primary workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: true,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
...overrides,
};
}
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
return {
id: "execution-workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 branch",
status: "active",
deliveryState: overrides.deliveryState ?? "unknown",
cwd: "/tmp/project/worktree",
repoUrl: null,
baseRef: null,
branchName: "pap-1",
providerType: "git_worktree",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-03-11T00:00:00.000Z"),
openedAt: new Date("2026-03-11T00:00:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
...overrides,
};
}
describe("inbox helpers", () => {
beforeEach(() => {
storage.clear();
});
it("resurfaces non-issue items when they change after dismissal", () => {
const dismissedAtByKey = buildInboxDismissedAtByKey([
{
id: "dismissal-1",
companyId: "company-1",
userId: "user-1",
itemKey: "approval:approval-1",
kind: "dismiss",
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
snoozedUntil: null,
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
]);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T00:30:00.000Z"),
),
).toBe(true);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T01:30:00.000Z"),
),
).toBe(false);
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];
expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
expect(issues).toHaveLength(2);
});
it("shows actionable approvals on mine, while recent and unread stay company-wide", () => {
const approvals = [
{
...makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
requestedByUserId: "user-1",
},
{
...makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
requestedByUserId: "user-2",
},
{
...makeApprovalWithTimestamps("approval-revision", "revision_requested", "2026-03-11T03:00:00.000Z"),
decidedByUserId: "user-1",
},
];
expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "unread", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "all", "resolved").map((approval) => approval.id)).toEqual([
"approval-approved",
]);
});
it("surfaces agent-requested actionable approvals in mine", () => {
const approvals = [
{
...makeApprovalWithTimestamps("approval-agent-requested", "pending", "2026-03-11T02:00:00.000Z"),
requestedByUserId: null,
},
{
...makeApprovalWithTimestamps("approval-unrelated-resolved", "approved", "2026-03-11T03:00:00.000Z"),
requestedByUserId: "user-2",
},
];
expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([
"approval-agent-requested",
]);
});
it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false);
olderIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-between",
"pending",
"2026-03-11T03:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [olderIssue, newerIssue],
approvals: [approval],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"approval:approval-between",
"issue:2",
]);
});
it("prefers canonical lastActivityAt over comment-only timestamps", () => {
const activityIssue = makeIssue("1", true);
activityIssue.lastExternalCommentAt = new Date("2026-03-11T01:00:00.000Z");
activityIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const commentIssue = makeIssue("2", true);
commentIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
commentIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
expect(getRecentTouchedIssues([commentIssue, activityIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
});
it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true);
issue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-oldest",
"pending",
"2026-03-11T02:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [issue],
approvals: [approval],
joinRequests: [joinRequest],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"join:join-1",
"approval:approval-oldest",
]);
});
it("skips hidden groups when building keyboard navigation entries", () => {
const visibleIssue = makeIssue("visible", true);
const hiddenIssue = makeIssue("hidden", true);
const approval = makeApprovalWithTimestamps("approval-1", "pending", "2026-03-11T03:00:00.000Z");
const entries = buildInboxKeyboardNavEntries(
[
{
key: "visible-group",
displayItems: [{ kind: "issue", timestamp: 3, issue: visibleIssue }],
childrenByIssueId: new Map(),
},
{
key: "hidden-group",
displayItems: [
{ kind: "issue", timestamp: 2, issue: hiddenIssue },
{ kind: "approval", timestamp: 1, approval },
],
childrenByIssueId: new Map(),
},
],
new Set(["hidden-group"]),
new Set(),
);
expect(entries).toEqual([
{
type: "top",
itemKey: `visible-group:${getInboxWorkItemKey({ kind: "issue", timestamp: 3, issue: visibleIssue })}`,
item: { kind: "issue", timestamp: 3, issue: visibleIssue },
},
]);
});
it("includes child issues only when their parent row is expanded", () => {
const parentIssue = makeIssue("parent", true);
const childIssue = makeIssue("child", true);
childIssue.parentId = parentIssue.id;
const groupedSections = [
{
key: "workspace:default",
displayItems: [{ kind: "issue", timestamp: 2, issue: parentIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map([[parentIssue.id, [childIssue]]]),
},
];
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()).map((entry) => entry.type === "top"
? entry.itemKey
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
childIssue.id,
]);
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set([parentIssue.id])).map((entry) => entry.type === "top"
? entry.itemKey
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
]);
});
it("keeps nested grandchild issues visible in keyboard navigation", () => {
const parentIssue = makeIssue("parent", true);
parentIssue.lastActivityAt = new Date("2026-03-11T01:00:00.000Z");
const childIssue = makeIssue("child", true);
childIssue.parentId = parentIssue.id;
childIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const grandchildIssue = makeIssue("grandchild", false);
grandchildIssue.parentId = childIssue.id;
grandchildIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const [section] = buildGroupedInboxSections(
getInboxWorkItems({ issues: [parentIssue, childIssue, grandchildIssue], approvals: [] }),
"none",
{},
{ nestingEnabled: true },
);
expect(section?.displayItems.map((item) => item.kind === "issue" ? item.issue.id : "other")).toEqual([
parentIssue.id,
]);
expect(section?.displayItems[0]?.timestamp).toBe(new Date("2026-03-11T05:00:00.000Z").getTime());
expect(
buildInboxKeyboardNavEntries([section!], new Set(), new Set()).map((entry) => entry.type === "top"
? entry.item.kind === "issue" ? entry.item.issue.id : "other"
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([parentIssue.id, childIssue.id, grandchildIssue.id]);
expect(
buildInboxKeyboardNavEntries([section!], new Set(), new Set([childIssue.id])).map((entry) => entry.type === "top"
? entry.item.kind === "issue" ? entry.item.issue.id : "other"
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([parentIssue.id, childIssue.id]);
});
it("stops cyclic child issue traversal when building keyboard navigation", () => {
const parentIssue = makeIssue("parent", true);
const childIssue = makeIssue("child", true);
childIssue.parentId = parentIssue.id;
parentIssue.parentId = childIssue.id;
const groupedSections = [
{
key: "workspace:default",
displayItems: [{ kind: "issue", timestamp: 2, issue: parentIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map([
[parentIssue.id, [childIssue]],
[childIssue.id, [parentIssue]],
]),
},
];
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()).map((entry) => entry.type === "top"
? entry.item.kind === "issue" ? entry.item.issue.id : "other"
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([parentIssue.id, childIssue.id]);
});
it("emits a group nav entry for labeled groups and omits children when the group is collapsed", () => {
const visibleIssue = makeIssue("visible", true);
const hiddenIssue = makeIssue("hidden", true);
const groupedSections = [
{
key: "priority:high",
label: "High priority",
displayItems: [{ kind: "issue", timestamp: 3, issue: visibleIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map(),
},
{
key: "priority:medium",
label: "Medium priority",
displayItems: [{ kind: "issue", timestamp: 2, issue: hiddenIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map(),
},
];
const expanded = buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set());
expect(expanded.map((entry) => entry.type)).toEqual(["group", "top", "group", "top"]);
expect(expanded[0]).toEqual({
type: "group",
groupKey: "priority:high",
label: "High priority",
collapsed: false,
});
const collapsed = buildInboxKeyboardNavEntries(
groupedSections,
new Set(["priority:medium"]),
new Set(),
);
expect(collapsed.map((entry) => entry.type)).toEqual(["group", "top", "group"]);
expect(collapsed[2]).toEqual({
type: "group",
groupKey: "priority:medium",
label: "Medium priority",
collapsed: true,
});
});
it("sorts self-touched issues without external comments by updatedAt", () => {
const recentSelfTouched = makeIssue("recent", false);
recentSelfTouched.lastExternalCommentAt = null as unknown as Date;
recentSelfTouched.updatedAt = new Date("2026-03-11T05:00:00.000Z");
recentSelfTouched.myLastTouchAt = new Date("2026-03-11T05:00:00.000Z");
const olderCommented = makeIssue("older", false);
olderCommented.lastExternalCommentAt = new Date("2026-03-11T03:00:00.000Z");
const items = getInboxWorkItems({
issues: [olderCommented, recentSelfTouched],
approvals: [],
});
expect(items.map((item) => (item.kind === "issue" ? item.issue.id : ""))).toEqual([
"recent",
"older",
]);
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({
tab: "mine",
hasItems: true,
showOnMine: true,
showOnRecent: false,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "recent",
hasItems: true,
showOnMine: false,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "unread",
hasItems: true,
showOnMine: true,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(false);
});
it("shows company alerts only on the all tab", () => {
expect(shouldShowCompanyAlerts("mine")).toBe(false);
expect(shouldShowCompanyAlerts("recent")).toBe(false);
expect(shouldShowCompanyAlerts("unread")).toBe(false);
expect(shouldShowCompanyAlerts("all")).toBe(true);
});
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);
issue.lastActivityAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
return issue;
});
const recentIssues = getRecentTouchedIssues(issues);
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
});
it("matches workspace names when inbox issue search includes workspace labels", () => {
const issue = makeIssue("workspace", false);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
issue.executionWorkspaceId = "execution-workspace-1";
expect(matchesInboxIssueSearch(
issue,
"feature",
{
isolatedWorkspacesEnabled: true,
executionWorkspaceById: new Map([
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace" as const, projectWorkspaceId: "project-workspace-1" }],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-2"]]),
},
)).toBe(true);
});
it("resolves the default workspace into an explicit grouping label", () => {
const issue = makeIssue("default", false);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
expect(resolveIssueWorkspaceGroup(issue, {
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-1"]]),
})).toEqual({
key: "workspace:project:project-workspace-1",
label: "Primary workspace (default)",
});
});
it("returns archived search matches that are not already visible in the inbox", () => {
const visibleIssue = makeIssue("visible", false);
visibleIssue.title = "Alpha visible task";
const archivedMatch = makeIssue("archived-match", false);
archivedMatch.title = "Alpha archived task";
const archivedMiss = makeIssue("archived-miss", false);
archivedMiss.title = "Different task";
expect(
getArchivedInboxSearchIssues({
visibleIssues: [visibleIssue],
searchableIssues: [visibleIssue, archivedMatch, archivedMiss],
query: "alpha",
}).map((issue) => issue.id),
).toEqual(["archived-match"]);
});
it("sorts archived search matches by most recent activity", () => {
const older = makeIssue("older", false);
older.title = "Alpha older";
older.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const newer = makeIssue("newer", false);
newer.title = "Alpha newer";
newer.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
expect(
getArchivedInboxSearchIssues({
visibleIssues: [],
searchableIssues: [older, newer],
query: "alpha",
}).map((issue) => issue.id),
).toEqual(["newer", "older"]);
});
it("adds remote issue results that are not already present in inbox search results", () => {
const remoteMatch = makeIssue("remote-match", false);
remoteMatch.status = "in_progress";
expect(
getInboxSearchSupplementIssues({
query: "pull/3303",
filteredWorkItems: [],
archivedSearchIssues: [],
remoteIssues: [remoteMatch],
issueFilters: {
statuses: ["in_progress"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}).map((issue) => issue.id),
).toEqual(["remote-match"]);
expect(
getInboxSearchSupplementIssues({
query: "pull/3303",
filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("remote-match", false) }],
archivedSearchIssues: [],
remoteIssues: [remoteMatch],
issueFilters: {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}),
).toEqual([]);
expect(
getInboxSearchSupplementIssues({
query: "pull/3303",
filteredWorkItems: [],
archivedSearchIssues: [makeIssue("remote-match", false)],
remoteIssues: [remoteMatch],
issueFilters: {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}),
).toEqual([]);
});
it("applies external-object filters to remote inbox search supplements", () => {
const failedMatch = makeIssue("failed-match", false);
const freshMatch = makeIssue("fresh-match", false);
const summaries = new Map<string, ExternalObjectSummary>([
["failed-match", {
total: 1,
byStatusCategory: { failed: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
["fresh-match", {
total: 1,
byStatusCategory: { succeeded: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "success",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
]);
expect(
getInboxSearchSupplementIssues({
query: "github",
filteredWorkItems: [],
archivedSearchIssues: [],
remoteIssues: [failedMatch, freshMatch],
issueFilters: {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: ["failed"],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
issueFilterContext: {
externalObjectSummaryByIssueId: summaries,
externalObjectSummariesReady: true,
},
}).map((issue) => issue.id),
).toEqual(["failed-match"]);
});
it("keeps inbox search matches ahead of archived and other result sections", () => {
const inboxIssue = makeIssue("inbox", false);
inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const archivedIssue = makeIssue("archived", false);
archivedIssue.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
const otherIssue = makeIssue("other", false);
otherIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const sections = [
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [inboxIssue], approvals: [] }),
"none",
{},
),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [archivedIssue], approvals: [] }),
"none",
{},
{ keyPrefix: "archived-search:", searchSection: "archived" },
),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [otherIssue], approvals: [] }),
"none",
{},
{ keyPrefix: "other-search:", searchSection: "other" },
),
];
expect(sections.map((section) => section.searchSection)).toEqual(["none", "archived", "other"]);
expect(
sections.map((section) => {
const [item] = section.displayItems;
return item?.kind === "issue" ? item.issue.id : null;
}),
).toEqual(["inbox", "archived", "other"]);
});
it("omits empty archived and other ungrouped search sections", () => {
expect(
buildGroupedInboxSections(
[],
"none",
{},
{ keyPrefix: "archived-search:", searchSection: "archived" },
),
).toEqual([]);
expect(
buildGroupedInboxSections(
[],
"none",
{},
{ keyPrefix: "other-search:", searchSection: "other" },
),
).toEqual([]);
});
it("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("mine");
saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all");
});
it("persists the blocked inbox tab", () => {
localStorage.clear();
saveLastInboxTab("blocked");
expect(loadLastInboxTab()).toBe("blocked");
});
it("persists inbox filters per company", () => {
saveInboxFilterPreferences("company-1", {
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
externalObjectStatuses: [],
showAgentCreatedTasks: true,
routineTasksOnly: false,
},
});
saveInboxFilterPreferences("company-2", {
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: [],
liveOnly: true,
externalObjectStatuses: [],
showAgentCreatedTasks: true,
routineTasksOnly: false,
},
});
expect(loadInboxFilterPreferences("company-2")).toEqual({
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
});
it("normalizes invalid inbox filter storage back to safe defaults", () => {
localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({
allCategoryFilter: "bogus",
allApprovalFilter: "bogus",
issueFilters: {
statuses: ["todo", 123],
priorities: "high",
assignees: ["agent-1"],
creators: ["user:user-1", 42],
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
liveOnly: "yes",
showAgentCreatedTasks: "yes",
},
}));
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: {
statuses: ["todo"],
priorities: [],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: [],
projects: ["project-1"],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
});
it("keeps nesting enabled on desktop when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
});
it("forces nesting off on mobile even when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, true)).toBe(false);
});
it("keeps nesting off when the saved preference is off", () => {
expect(resolveInboxNestingEnabled(false, false)).toBe(false);
expect(resolveInboxNestingEnabled(false, true)).toBe(false);
});
it("defaults issue columns to the current inbox layout", () => {
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
});
it("normalizes saved issue columns to valid values in canonical order", () => {
saveInboxIssueColumns(["labels", "updated", "status", "workspace", "labels", "assignee"]);
expect(loadInboxIssueColumns()).toEqual(["status", "assignee", "workspace", "labels", "updated"]);
expect(normalizeInboxIssueColumns(["project", "workspace", "wat", "id"])).toEqual(["id", "project", "workspace"]);
});
it("hides the workspace column option unless isolated workspaces are enabled", () => {
expect(getAvailableInboxIssueColumns(false)).toEqual([
"status",
"id",
"assignee",
"kickedOffBy",
"project",
"parent",
"labels",
"updated",
]);
expect(getAvailableInboxIssueColumns(true)).toEqual([
"status",
"id",
"assignee",
"kickedOffBy",
"project",
"workspace",
"parent",
"labels",
"updated",
]);
});
it("allows hiding every optional issue column down to the title-only view", () => {
saveInboxIssueColumns([]);
expect(loadInboxIssueColumns()).toEqual([]);
});
it("shows explicit workspace names but leaves the default workspace blank", () => {
const issue = makeIssue("1", true);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
issue.executionWorkspaceId = "execution-workspace-1";
const executionWorkspace = makeExecutionWorkspace();
const defaultWorkspace = makeProjectWorkspace();
const secondaryWorkspace = makeProjectWorkspace({
id: "project-workspace-2",
name: "Secondary workspace",
isPrimary: false,
});
expect(
resolveIssueWorkspaceName(issue, {
executionWorkspaceById: new Map([[executionWorkspace.id, executionWorkspace]]),
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBe("PAP-1 branch");
issue.executionWorkspaceId = null;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
issue.projectWorkspaceId = secondaryWorkspace.id;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBe("Secondary workspace");
issue.projectWorkspaceId = null;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
issue.executionWorkspaceId = "execution-workspace-shared-default";
issue.projectWorkspaceId = defaultWorkspace.id;
expect(
resolveIssueWorkspaceName(issue, {
executionWorkspaceById: new Map([[
issue.executionWorkspaceId,
makeExecutionWorkspace({
id: issue.executionWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
projectWorkspaceId: defaultWorkspace.id,
name: "PAP-1067",
}),
]]),
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
});
it("maps legacy new-tab storage to mine", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("mine");
});
it("enables swipe archive only on the mine tab", () => {
expect(isMineInboxTab("mine")).toBe(true);
expect(isMineInboxTab("recent")).toBe(false);
expect(isMineInboxTab("unread")).toBe(false);
expect(isMineInboxTab("all")).toBe(false);
});
it("anchors Mine selection to the first available inbox row", () => {
expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
});
it("selects the first row only after keyboard navigation starts", () => {
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
});
it("groups mixed inbox items by type while preserving item order within each group", () => {
const items: InboxWorkItem[] = [
{ kind: "approval", timestamp: 4, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 3, issue: makeIssue("1", true) },
{ kind: "issue", timestamp: 2, issue: makeIssue("2", false) },
{ kind: "failed_run", timestamp: 1, run: makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z") },
{ kind: "join_request", timestamp: 0, joinRequest: makeJoinRequest("join-1") },
];
expect(groupInboxWorkItems(items, "none")).toEqual([{ key: "__all", label: null, items }]);
expect(groupInboxWorkItems(items, "type")).toEqual([
{ key: "issue", label: "Tasks", items: [items[1], items[2]] },
{ key: "approval", label: "Approvals", items: [items[0]] },
{ key: "failed_run", label: "Failed runs", items: [items[3]] },
{ key: "join_request", label: "Join requests", items: [items[4]] },
]);
});
it("groups workspace sections by latest issue activity while preserving non-issue sections", () => {
const defaultIssue = makeIssue("default", true);
defaultIssue.projectId = "project-1";
defaultIssue.projectWorkspaceId = "project-workspace-1";
const sharedDefaultIssue = makeIssue("shared-default", true);
sharedDefaultIssue.projectId = "project-1";
sharedDefaultIssue.projectWorkspaceId = "project-workspace-1";
sharedDefaultIssue.executionWorkspaceId = "execution-workspace-shared-default";
const featureIssue = makeIssue("feature", false);
featureIssue.projectId = "project-1";
featureIssue.projectWorkspaceId = "project-workspace-2";
const execIssue = makeIssue("exec", false);
execIssue.projectId = "project-1";
execIssue.projectWorkspaceId = "project-workspace-1";
execIssue.executionWorkspaceId = "execution-workspace-1";
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 5, issue: defaultIssue },
{ kind: "approval", timestamp: 2, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 4, issue: sharedDefaultIssue },
{ kind: "issue", timestamp: 7, issue: featureIssue },
{ kind: "issue", timestamp: 9, issue: execIssue },
];
expect(groupInboxWorkItems(items, "workspace", {
executionWorkspaceById: new Map([
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace", projectWorkspaceId: "project-workspace-1" }],
["execution-workspace-shared-default", { name: "Shared default workspace", mode: "shared_workspace", projectWorkspaceId: "project-workspace-1" }],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
["project-workspace-2", { name: "Secondary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-1"]]),
})).toEqual([
{ key: "workspace:execution:execution-workspace-1", label: "Feature Branch", items: [items[4]] },
{ key: "workspace:project:project-workspace-2", label: "Secondary workspace", items: [items[3]] },
{
key: "workspace:project:project-workspace-1",
label: "Primary workspace (default)",
items: [items[0], items[2]],
},
{ key: "kind:approval", label: "Approvals", items: [items[1]] },
]);
});
it("groups assignee sections by latest issue activity while preserving non-issue sections", () => {
const agentIssue = makeIssue("agent", true);
agentIssue.assigneeAgentId = "agent-1";
const userIssue = makeIssue("user", false);
userIssue.assigneeUserId = "user-1";
const unassignedIssue = makeIssue("unassigned", false);
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 5, issue: agentIssue },
{ kind: "approval", timestamp: 8, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 7, issue: userIssue },
{ kind: "issue", timestamp: 2, issue: unassignedIssue },
];
expect(groupInboxWorkItems(items, "assignee", {
agentById: new Map([["agent-1", "Coder"]]),
userLabelById: new Map([["user-1", "Riley"]]),
})).toEqual([
{ key: "kind:approval", label: "Approvals", items: [items[1]] },
{ key: "assignee:user:user-1", label: "Riley", items: [items[2]] },
{ key: "assignee:agent:agent-1", label: "Coder", items: [items[0]] },
{ key: "assignee:none", label: "Unassigned", items: [items[3]] },
]);
});
it("groups project sections by latest issue activity while preserving non-issue sections", () => {
const paperclipIssue = makeIssue("paperclip", true);
paperclipIssue.projectId = "project-1";
const onboardingIssue = makeIssue("onboarding", false);
onboardingIssue.projectId = "project-2";
const noProjectIssue = makeIssue("no-project", false);
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 9, issue: paperclipIssue },
{ kind: "issue", timestamp: 4, issue: onboardingIssue },
{ kind: "join_request", timestamp: 6, joinRequest: makeJoinRequest("join-1") },
{ kind: "issue", timestamp: 2, issue: noProjectIssue },
];
expect(groupInboxWorkItems(items, "project", {
projectById: new Map([
["project-1", { name: "Paperclip App" }],
["project-2", { name: "Onboarding" }],
]),
})).toEqual([
{ key: "project:project-1", label: "Paperclip App", items: [items[0]] },
{ key: "kind:join_request", label: "Join requests", items: [items[2]] },
{ key: "project:project-2", label: "Onboarding", items: [items[1]] },
{ key: "project:none", label: "No project", items: [items[3]] },
]);
});
it("builds new issue defaults from inbox project, assignee, and workspace groups", () => {
const projectIssue = makeIssue("project", true);
projectIssue.projectId = "project-1";
const executionIssue = makeIssue("exec", false);
executionIssue.projectId = "project-1";
executionIssue.projectWorkspaceId = "project-workspace-1";
executionIssue.executionWorkspaceId = "execution-workspace-1";
const agentIssue = makeIssue("agent", false);
agentIssue.assigneeAgentId = "agent-1";
const options = {
executionWorkspaceById: new Map([
[
"execution-workspace-1",
{
name: "Feature Branch",
mode: "isolated_workspace" as const,
projectWorkspaceId: "project-workspace-1",
},
],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace", projectId: "project-1" }],
]),
};
expect(buildInboxIssueGroupCreateDefaults(
"project:project-1",
"project",
[{ kind: "issue", timestamp: 1, issue: projectIssue }],
options,
)).toEqual({ projectId: "project-1" });
expect(buildInboxIssueGroupCreateDefaults(
"workspace:execution:execution-workspace-1",
"workspace",
[{ kind: "issue", timestamp: 1, issue: executionIssue }],
options,
)).toEqual({
executionWorkspaceId: "execution-workspace-1",
executionWorkspaceMode: "reuse_existing",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
});
expect(buildInboxIssueGroupCreateDefaults(
"assignee:agent:agent-1",
"assignee",
[{ kind: "issue", timestamp: 1, issue: agentIssue }],
options,
)).toEqual({ assigneeAgentId: "agent-1" });
});
it("persists inbox grouping preferences", () => {
saveInboxWorkItemGroupBy("workspace");
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
saveInboxWorkItemGroupBy("assignee");
expect(loadInboxWorkItemGroupBy()).toBe("assignee");
saveInboxWorkItemGroupBy("project");
expect(loadInboxWorkItemGroupBy()).toBe("project");
});
it("persists collapsed inbox groups per company", () => {
saveCollapsedInboxGroupKeys("company-1", new Set(["workspace:alpha", "workspace:beta"]));
saveCollapsedInboxGroupKeys("company-2", new Set(["type:approval"]));
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set(["workspace:alpha", "workspace:beta"]));
expect(loadCollapsedInboxGroupKeys("company-2")).toEqual(new Set(["type:approval"]));
});
it("returns empty collapsed inbox groups for missing or invalid storage", () => {
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
localStorage.setItem("paperclip:inbox:collapsed-groups:company-1", JSON.stringify({ nope: true }));
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
});
it("persists collapsed inbox parents per company", () => {
saveCollapsedInboxParentIds("company-1", new Set(["parent-1", "parent-2"]));
saveCollapsedInboxParentIds("company-2", new Set(["parent-3"]));
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set(["parent-1", "parent-2"]));
expect(loadCollapsedInboxParentIds("company-2")).toEqual(new Set(["parent-3"]));
saveCollapsedInboxParentIds("company-1", new Set());
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
});
it("returns empty collapsed inbox parents for missing or invalid storage", () => {
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
localStorage.setItem("paperclip:inbox:collapsed-parents:company-1", JSON.stringify({ nope: true }));
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
});
it("does not reset workspace grouping before experimental settings have loaded", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false);
});
it("resets workspace grouping only when settings are loaded and workspace grouping is unavailable", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, true)).toBe(true);
expect(shouldResetInboxWorkspaceGrouping("workspace", true, true)).toBe(false);
expect(shouldResetInboxWorkspaceGrouping("none", false, true)).toBe(false);
});
});