Fix inbox keyboard navigation across groups

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
1 parent d2c68b4893
commit 3a02a7de49
3 files changed
+154 -57

No files matched your search

+67
View File
@@ -12,11 +12,13 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
filterInboxIssues,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
@@ -490,6 +492,71 @@ describe("inbox helpers", () => {
]);
});
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.issueId),
).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.issueId),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
]);
});
it("sorts self-touched issues without external comments by updatedAt", () => {
const recentSelfTouched = makeIssue("recent", false);
recentSelfTouched.lastExternalCommentAt = null as unknown as Date;
+60
View File
@@ -86,6 +86,24 @@ export interface InboxWorkItemGroup {
items: InboxWorkItem[];
}
export interface InboxKeyboardGroupSection {
key: string;
displayItems: InboxWorkItem[];
childrenByIssueId: ReadonlyMap<string, Issue[]>;
}
export type InboxKeyboardNavEntry =
| {
type: "top";
itemKey: string;
item: InboxWorkItem;
}
| {
type: "child";
issueId: string;
issue: Issue;
};
export interface InboxProjectWorkspaceLookup {
name: string;
}
@@ -854,6 +872,48 @@ export function buildInboxNesting(items: InboxWorkItem[]): {
return { displayItems, childrenByIssueId };
}
export function getInboxWorkItemKey(item: InboxWorkItem): string {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "failed_run") return `run:${item.run.id}`;
return `join:${item.joinRequest.id}`;
}
export function buildInboxKeyboardNavEntries(
groupedSections: ReadonlyArray<InboxKeyboardGroupSection>,
collapsedGroupKeys: ReadonlySet<string>,
collapsedInboxParents: ReadonlySet<string>,
): InboxKeyboardNavEntry[] {
const entries: InboxKeyboardNavEntry[] = [];
for (const group of groupedSections) {
if (collapsedGroupKeys.has(group.key)) continue;
for (const item of group.displayItems) {
entries.push({
type: "top",
itemKey: `${group.key}:${getInboxWorkItemKey(item)}`,
item,
});
if (item.kind !== "issue") continue;
const children = group.childrenByIssueId.get(item.issue.id);
if (!children?.length || collapsedInboxParents.has(item.issue.id)) continue;
for (const child of children) {
entries.push({
type: "child",
issueId: child.id,
issue: child,
});
}
}
}
return entries;
}
export function shouldShowInboxSection({
tab,
hasItems,
+27 -57
View File
@@ -95,12 +95,14 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
import {
ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxKeyboardNavEntries,
buildInboxNesting,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
getApprovalsForTab,
getArchivedInboxSearchIssues,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getInboxWorkItems,
getInboxSearchSupplementIssues,
getLatestFailedRunsByAgent,
matchesInboxIssueSearch,
@@ -125,6 +127,7 @@ import {
type InboxCategoryFilter,
type InboxFilterPreferences,
type InboxIssueColumn,
type InboxKeyboardNavEntry,
saveLastInboxTab,
shouldShowInboxSection,
type InboxTab,
@@ -140,9 +143,7 @@ type SectionKey =
| "alerts";
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
type NavEntry =
| { type: "top"; index: number; item: InboxWorkItem }
| { type: "child"; parentIndex: number; issue: Issue };
type NavEntry = InboxKeyboardNavEntry;
type InboxGroupedSection = {
key: string;
@@ -1136,27 +1137,24 @@ export function Inbox() {
});
}, []);
// Build flat navigation list including expanded children for keyboard traversal
// Build flat navigation list from visible rows so keyboard traversal respects collapsed groups.
const flatNavItems = useMemo((): NavEntry[] => {
const entries: NavEntry[] = [];
let topIndex = 0;
for (const group of groupedSections) {
for (const item of group.displayItems) {
entries.push({ type: "top", index: topIndex, item });
if (item.kind === "issue") {
const children = group.childrenByIssueId.get(item.issue.id);
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
if (isExpanded) {
for (const child of children) {
entries.push({ type: "child", parentIndex: topIndex, issue: child });
}
}
}
topIndex += 1;
}
}
return entries;
}, [groupedSections, collapsedInboxParents]);
return buildInboxKeyboardNavEntries(groupedSections, collapsedGroupKeys, collapsedInboxParents);
}, [collapsedGroupKeys, collapsedInboxParents, groupedSections]);
const topFlatIndex = useMemo(() => {
const map = new Map<string, number>();
flatNavItems.forEach((entry, index) => {
if (entry.type === "top") map.set(entry.itemKey, index);
});
return map;
}, [flatNavItems]);
const childFlatIndex = useMemo(() => {
const map = new Map<string, number>();
flatNavItems.forEach((entry, index) => {
if (entry.type === "child") map.set(entry.issueId, index);
});
return map;
}, [flatNavItems]);
const agentName = (id: string | null) => {
if (!id) return null;
@@ -1478,13 +1476,6 @@ export function Inbox() {
return "hidden";
};
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "failed_run") return `run:${item.run.id}`;
return `join:${item.joinRequest.id}`;
}, []);
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
@@ -1624,7 +1615,7 @@ export function Inbox() {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
const key = getInboxWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
}
}
@@ -1638,7 +1629,7 @@ export function Inbox() {
act.markUnreadIssue(issue.id);
} else if (item) {
if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
else act.markNonIssueUnread(getWorkItemKey(item));
else act.markNonIssueUnread(getInboxWorkItemKey(item));
}
break;
}
@@ -1652,7 +1643,7 @@ export function Inbox() {
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
} else {
const key = getWorkItemKey(item);
const key = getInboxWorkItemKey(item);
if (!st.readItems.has(key)) act.markNonIssueRead(key);
}
}
@@ -1691,7 +1682,7 @@ export function Inbox() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
}, [issueLinkState, keyboardShortcutsEnabled]);
// Scroll selected item into view
useEffect(() => {
@@ -2001,27 +1992,6 @@ export function Inbox() {
<div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{(() => {
// Pre-compute flat nav index for each top-level item and child issue.
let flatIdx = 0;
const topFlatIndex = new Map<string, number>();
const childFlatIndex = new Map<string, number>();
for (const group of groupedSections) {
for (const topItem of group.displayItems) {
const itemKey = `${group.key}:${getWorkItemKey(topItem)}`;
topFlatIndex.set(itemKey, flatIdx);
flatIdx++;
if (topItem.kind === "issue") {
const children = group.childrenByIssueId.get(topItem.issue.id);
const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id);
if (isExpanded) {
for (const child of children) {
childFlatIndex.set(child.id, flatIdx);
flatIdx++;
}
}
}
}
}
const renderInboxIssue = ({
issue,
depth,
@@ -2171,7 +2141,7 @@ export function Inbox() {
for (let index = 0; index < group.displayItems.length; index += 1) {
const item = group.displayItems[index]!;
const navIdx = topFlatIndex.get(`${group.key}:${getWorkItemKey(item)}`) ?? 0;
const navIdx = topFlatIndex.get(`${group.key}:${getInboxWorkItemKey(item)}`) ?? 0;
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}