Merge pull request #3356 from cryppadotta/pap-1331-inbox-ux

feat: polish inbox and issue list workflows
This commit is contained in:
Dotta 2026-04-11 06:35:59 -05:00 committed by GitHub
commit 45ebecab5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1695 additions and 390 deletions

View file

@ -15,6 +15,7 @@ import {
buildInboxDismissedAtByKey,
computeInboxBadgeData,
filterInboxIssues,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns,
getApprovalsForTab,
getInboxWorkItems,
@ -24,13 +25,16 @@ import {
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveInboxIssueColumns,
saveLastInboxTab,
shouldShowInboxSection,
@ -548,6 +552,65 @@ describe("inbox helpers", () => {
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("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("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("mine");
@ -556,6 +619,92 @@ describe("inbox helpers", () => {
expect(loadLastInboxTab()).toBe("all");
});
it("persists inbox filters per company", () => {
saveInboxFilterPreferences("company-1", {
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
},
});
saveInboxFilterPreferences("company-2", {
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
},
});
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
},
});
expect(loadInboxFilterPreferences("company-2")).toEqual({
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: 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"],
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
showRoutineExecutions: "yes",
},
}));
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: {
statuses: ["todo"],
priorities: [],
assignees: ["agent-1"],
labels: [],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: false,
},
});
});
it("keeps nesting enabled on desktop when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
});

View file

@ -6,6 +6,10 @@ import type {
Issue,
JoinRequest,
} from "@paperclipai/shared";
import {
defaultIssueFilterState,
type IssueFilterState,
} from "./issue-filters";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@ -16,12 +20,34 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
export const inboxIssueColumns = [
"status",
"id",
"assignee",
"project",
"workspace",
"parent",
"labels",
"updated",
] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
export interface InboxFilterPreferences {
allCategoryFilter: InboxCategoryFilter;
allApprovalFilter: InboxApprovalFilter;
issueFilters: IssueFilterState;
}
export type InboxWorkItem =
| {
kind: "issue";
@ -59,6 +85,104 @@ export interface InboxWorkItemGroup {
items: InboxWorkItem[];
}
const defaultInboxFilterPreferences: InboxFilterPreferences = {
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: defaultIssueFilterState,
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function normalizeIssueFilterState(value: unknown): IssueFilterState {
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
return {
statuses: normalizeStringArray(candidate.statuses),
priorities: normalizeStringArray(candidate.priorities),
assignees: normalizeStringArray(candidate.assignees),
labels: normalizeStringArray(candidate.labels),
projects: normalizeStringArray(candidate.projects),
workspaces: normalizeStringArray(candidate.workspaces),
showRoutineExecutions: candidate.showRoutineExecutions === true,
};
}
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
return value === "issues_i_touched"
|| value === "join_requests"
|| value === "approvals"
|| value === "failed_runs"
|| value === "alerts"
? value
: "everything";
}
function normalizeInboxApprovalFilter(value: unknown): InboxApprovalFilter {
return value === "actionable" || value === "resolved" ? value : "all";
}
function getInboxFilterPreferencesStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
}
export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
issueFilters: normalizeIssueFilterState(parsed.issueFilters),
};
} catch {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
}
export function saveInboxFilterPreferences(
companyId: string | null | undefined,
preferences: InboxFilterPreferences,
) {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(
storageKey,
JSON.stringify({
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
issueFilters: normalizeIssueFilterState(preferences.issueFilters),
}),
);
} catch {
// Ignore localStorage failures.
}
}
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
@ -174,6 +298,78 @@ export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolea
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
}
export function matchesInboxIssueSearch(
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
query: string,
{
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
} = {},
): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return true;
if (issue.title.toLowerCase().includes(normalizedQuery)) return true;
if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true;
if (issue.description?.toLowerCase().includes(normalizedQuery)) return true;
if (!isolatedWorkspacesEnabled) return false;
const workspaceName = resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
return workspaceName?.toLowerCase().includes(normalizedQuery) ?? false;
}
export function getArchivedInboxSearchIssues({
visibleIssues,
searchableIssues,
query,
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
visibleIssues: Issue[];
searchableIssues: Issue[];
query: string;
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id));
return searchableIssues
.filter((issue) => !visibleIssueIds.has(issue.id))
.filter((issue) =>
matchesInboxIssueSearch(issue, normalizedQuery, {
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}),
)
.sort(sortIssuesByMostRecentActivity);
}
export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{

View file

@ -6,6 +6,7 @@ export type IssueFilterState = {
assignees: string[];
labels: string[];
projects: string[];
workspaces: string[];
showRoutineExecutions: boolean;
};
@ -15,6 +16,7 @@ export const defaultIssueFilterState: IssueFilterState = {
assignees: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
};
@ -43,6 +45,12 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
}
export function resolveIssueFilterWorkspaceId(
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
): string | null {
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
}
export function applyIssueFilters(
issues: Issue[],
state: IssueFilterState,
@ -71,6 +79,12 @@ export function applyIssueFilters(
if (state.projects.length > 0) {
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
}
if (state.workspaces.length > 0) {
result = result.filter((issue) => {
const workspaceId = resolveIssueFilterWorkspaceId(issue);
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
return result;
}
@ -84,6 +98,7 @@ export function countActiveIssueFilters(
if (state.assignees.length > 0) count += 1;
if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
return count;
}

View file

@ -1,11 +1,15 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
findPageSearchShortcutTarget,
focusPageSearchShortcutTarget,
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction,
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "./keyboardShortcuts";
describe("keyboardShortcuts helpers", () => {
@ -40,6 +44,72 @@ describe("keyboardShortcuts helpers", () => {
expect(hasBlockingShortcutDialog(root)).toBe(false);
});
it("finds the visible page search shortcut target", () => {
const root = document.createElement("div");
const hidden = document.createElement("input");
hidden.setAttribute("data-page-search-target", "true");
vi.spyOn(hidden, "getClientRects").mockReturnValue([] as unknown as DOMRectList);
const visible = document.createElement("input");
visible.setAttribute("data-page-search-target", "true");
vi.spyOn(visible, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
root.append(hidden, visible);
document.body.appendChild(root);
expect(findPageSearchShortcutTarget(root)).toBe(visible);
root.remove();
});
it("focuses and selects the page search shortcut target", () => {
const root = document.createElement("div");
const input = document.createElement("input");
input.value = "existing query";
input.setAttribute("data-page-search-target", "true");
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
root.appendChild(input);
document.body.appendChild(root);
expect(focusPageSearchShortcutTarget(root)).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(input.value.length);
root.remove();
});
it("blurs page search on a plain Enter press", () => {
expect(shouldBlurPageSearchOnEnter({
key: "Enter",
isComposing: false,
})).toBe(true);
});
it("keeps focus while composing with an IME", () => {
expect(shouldBlurPageSearchOnEnter({
key: "Enter",
isComposing: true,
})).toBe(false);
});
it("blurs page search on Escape when the field is already empty", () => {
expect(shouldBlurPageSearchOnEscape({
key: "Escape",
isComposing: false,
currentValue: "",
})).toBe(true);
});
it("keeps focus on the first Escape while the field still has text", () => {
expect(shouldBlurPageSearchOnEscape({
key: "Escape",
isComposing: false,
currentValue: "query",
})).toBe(false);
});
it("archives only the first clean y press", () => {
const button = document.createElement("button");

View file

@ -8,6 +8,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
"[role='combobox']",
].join(", ");
const PAGE_SEARCH_SHORTCUT_SELECTOR = "[data-page-search-target='true']";
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
@ -23,6 +24,56 @@ export function hasBlockingShortcutDialog(root: ParentNode = document): boolean
return !!root.querySelector("[role='dialog'][aria-modal='true']");
}
function isVisibleShortcutTarget(element: HTMLElement): boolean {
if (!element.isConnected) return false;
if ("disabled" in element && typeof element.disabled === "boolean" && element.disabled) return false;
if (element.closest("[hidden], [aria-hidden='true'], [inert]")) return false;
if (element.closest("[role='dialog'][aria-modal='true']")) return false;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") return false;
return element.getClientRects().length > 0 || element === document.activeElement;
}
export function findPageSearchShortcutTarget(root: ParentNode = document): HTMLElement | null {
const candidates = Array.from(root.querySelectorAll<HTMLElement>(PAGE_SEARCH_SHORTCUT_SELECTOR));
return candidates.find((candidate) => isVisibleShortcutTarget(candidate)) ?? null;
}
export function focusPageSearchShortcutTarget(root: ParentNode = document): boolean {
const target = findPageSearchShortcutTarget(root);
if (!target) return false;
target.focus();
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
target.select();
}
return true;
}
export function shouldBlurPageSearchOnEnter({
key,
isComposing,
}: {
key: string;
isComposing: boolean;
}): boolean {
return key === "Enter" && !isComposing;
}
export function shouldBlurPageSearchOnEscape({
key,
isComposing,
currentValue,
}: {
key: string;
isComposing: boolean;
currentValue: string;
}): boolean {
return key === "Escape" && !isComposing && currentValue.length === 0;
}
export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEYS.has(key);
}

View file

@ -2,12 +2,19 @@ import * as React from "react";
import * as RouterDom from "react-router-dom";
import type { NavigateOptions, To } from "react-router-dom";
import { useCompany } from "@/context/CompanyContext";
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
import {
applyCompanyPrefix,
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
} from "@/lib/company-routes";
function parseIssuePathIdFromPath(pathname: string | null | undefined): string | null {
if (!pathname) return null;
const match = pathname.match(/(?:^|\/)issues\/([^/?#]+)/);
return match?.[1] ?? null;
}
function resolveTo(to: To, companyPrefix: string | null): To {
if (typeof to === "string") {
return applyCompanyPrefix(to, companyPrefix);
@ -40,10 +47,23 @@ function useActiveCompanyPrefix(): string | null {
export * from "react-router-dom";
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
function CompanyLink({ to, ...props }, ref) {
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
disableIssueQuicklook?: boolean;
};
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
const companyPrefix = useActiveCompanyPrefix();
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
const resolvedTo = resolveTo(to, companyPrefix);
const issuePathId = disableIssueQuicklook
? null
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
if (issuePathId) {
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
}
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
},
);