From 3a02a7de49622f09664f7c31cf84dea3d25189a4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 21:26:56 -0500 Subject: [PATCH] Fix inbox keyboard navigation across groups --- ui/src/lib/inbox.test.ts | 67 ++++++++++++++++++++++++++++++++ ui/src/lib/inbox.ts | 60 ++++++++++++++++++++++++++++ ui/src/pages/Inbox.tsx | 84 +++++++++++++--------------------------- 3 files changed, 154 insertions(+), 57 deletions(-) diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index d5b902c7253..70ecf497963 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -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; diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index c54e80dc6cb..abfe2c7db5e 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -86,6 +86,24 @@ export interface InboxWorkItemGroup { items: InboxWorkItem[]; } +export interface InboxKeyboardGroupSection { + key: string; + displayItems: InboxWorkItem[]; + childrenByIssueId: ReadonlyMap; +} + +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, + collapsedGroupKeys: ReadonlySet, + collapsedInboxParents: ReadonlySet, +): 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, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index a2281c8c072..3e8362e708a 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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(); + flatNavItems.forEach((entry, index) => { + if (entry.type === "top") map.set(entry.itemKey, index); + }); + return map; + }, [flatNavItems]); + const childFlatIndex = useMemo(() => { + const map = new Map(); + 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() {
{(() => { - // Pre-compute flat nav index for each top-level item and child issue. - let flatIdx = 0; - const topFlatIndex = new Map(); - const childFlatIndex = new Map(); - 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) => (