mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The inbox collects requests that need a human decision. > - Teams need to know who owns each request and who may act on it. > - A shared close operation must differ from hiding a card for one person. > - This pull request adds personal routing and durable outcomes to the existing attention service. > - People can claim or transfer work and inspect what another person did for them. > - Domain permissions still decide who may resolve each request. ## Linked Issues or Issue Description **Subsystem affected** Server, shared contracts, and database. This is the routing foundation for a stacked inbox UI change. **Problem or motivation** A company-wide attention feed does not give teams a clear personal queue. Archive, withdrawal, transfer, and decisions by another person need distinct scopes and records. **Proposed solution** Resolve a request audience from its addressee, task participants, and configured owners. Provide personal, unassigned, company, all-assignee, and archived views. Add claim, transfer, personal archive and snooze, shared withdrawal, receipts, and personal history. **Alternatives considered** A single shared inbox leaves ownership and personal hiding ambiguous. The service keeps one canonical request and projects its audience instead of copying requests for each person. **Roadmap alignment** This extends the existing Multiple Human Users and Agent Reviews and Approvals capabilities in ROADMAP.md. **Additional context** Refs #19363 and #19520 for the current inbox navigation and confirmation replacement rules. Refs #20268 for related actionable-request work. That PR filters actionability; this stack adds audience routing and personal outcomes. This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this branch. ## What Changed - Add nullable company and project decision owners and a company budget owner. - Add audience views and personal badge counts to the canonical attention projections. - Add claim, transfer, withdrawal, personal archive, snooze, and restore operations with permission checks and activity records. Lock addressed transfers to the current addressee or routing administrators, and validate the recipient against canonical resolution policies. - Add receipts and scoped history for work handled by another person. - Keep closed-task requests and replaced confirmations out of current cards. - Add an idempotent migration and a concurrent receipt-history index. - Update typed fixtures so the existing UI remains compatible with the new contracts. ## Verification - Server layered typechecking passes, including all three test-tree partitions. - Seven routing and interaction-resolution suites pass all 249 tests against a dedicated external PostgreSQL test database on `4caecc21d8`. Six new regressions failed before the repair; they cover takeover followed by approval, legitimate delegation, excluded creators/review requesters, and unauthorized claims. - Prior focused verification covers private approval history and receipts, transfer visibility, audience escalation, withdrawal governance, hidden-card counts, per-recipient confirmation replacement, and SDK source contracts. - A synthetic three-person company passed 37 routing and lifecycle acceptance checks. The UI stack adds browser acceptance for the shared controls. - The complete [GitHub Actions CI matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331) passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24: typecheck, build, general and serialized tests, Runner support, browser shards, and aggregate gates). Greptile is 5/5 on that exact head with no open findings. The fresh security scan passes after explicitly rechecking the addressee-transfer repair. - `pnpm verify` reports exit 2 because a CI-global script changed; full CI supplies that broader coverage. Embedded PostgreSQL could not start on this Mac because shared-memory slots were exhausted, so local database checks use a separate external test database. Linux CI covers the embedded setup. ## Risks - Personal badge counts change to the viewer's assigned requests. Accessible feed totals and task membership keep their existing scope. - The migration adds nullable owner fields and a dismissal fingerprint. Its index runs outside a transaction. The migration is classified and rerunnable. - Routing changes the addressee used by resolution authorization. The current addressee or a routing administrator must authorize an addressed transfer; the recipient must satisfy writer access, canonical resolver exclusions, and task review policy under row locks. - The shared validator export barrel crosses 1,000 lines because it exports the new attention schemas. Schema implementation stays in the dedicated attention validator module. ## Model Used OpenAI GPT-6 through Codex. Used repository tools, code execution, and browser checks. This session does not expose a more specific runtime model ID or its context-window size. ## 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 described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (OpenAPI contracts and docs/api/attention.md) - [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: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
1469 lines
48 KiB
TypeScript
1469 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,
|
|
fingerprint: 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);
|
|
});
|
|
});
|