fix(ui): make j/k keyboard shortcuts traverse nested child issues in inbox

Builds a flat navigation list that includes expanded child issues alongside
top-level items, so j/k moves through every visible row including children.
Also adds the NavEntry type and updates archive/read/enter actions to work
with both top-level work items and nested child issues.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-08 08:10:06 -05:00
parent d3e66c789e
commit 58ae23aa2c

View file

@ -935,6 +935,25 @@ export function Inbox() {
}); });
}, []); }, []);
// Build flat navigation list including expanded children for keyboard traversal
const flatNavItems = useMemo((): NavEntry[] => {
const entries: NavEntry[] = [];
for (let i = 0; i < nestedWorkItems.length; i++) {
const item = nestedWorkItems[i];
entries.push({ type: "top", index: i, item });
if (item.kind === "issue") {
const children = 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: i, issue: child });
}
}
}
}
return entries;
}, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]);
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
if (!id) return null; if (!id) return null;
return agentById.get(id) ?? null; return agentById.get(id) ?? null;
@ -1204,12 +1223,13 @@ export function Inbox() {
// Keep selection valid when the list shape changes, but do not auto-select on initial load. // Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => { useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, nestedWorkItems.length)); setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
}, [nestedWorkItems.length]); }, [flatNavItems.length]);
// Use refs for keyboard handler to avoid stale closures // Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({ const kbStateRef = useRef({
workItems: nestedWorkItems, workItems: nestedWorkItems,
flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivingIssueIds, archivingIssueIds,
@ -1219,6 +1239,7 @@ export function Inbox() {
}); });
kbStateRef.current = { kbStateRef.current = {
workItems: nestedWorkItems, workItems: nestedWorkItems,
flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivingIssueIds, archivingIssueIds,
@ -1272,77 +1293,94 @@ export function Inbox() {
// Keyboard shortcuts are only active on the "mine" tab // Keyboard shortcuts are only active on the "mine" tab
if (!st.canArchive) return; if (!st.canArchive) return;
const itemCount = st.workItems.length; const navItems = st.flatNavItems;
if (itemCount === 0) return; const navCount = navItems.length;
if (navCount === 0) return;
/** Resolve the nav entry at selectedIndex to an issue (for child entries) or work item. */
const resolveNavEntry = (idx: number): { issue?: Issue; item?: InboxWorkItem } => {
const entry = navItems[idx];
if (!entry) return {};
if (entry.type === "child") return { issue: entry.issue };
return { item: entry.item };
};
switch (e.key) { switch (e.key) {
case "j": { case "j": {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next")); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
break; break;
} }
case "k": { case "k": {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous")); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
break; break;
} }
case "a": case "a":
case "y": { case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault(); e.preventDefault();
const item = st.workItems[st.selectedIndex]; const { issue, item } = resolveNavEntry(st.selectedIndex);
if (item.kind === "issue") { if (issue) {
if (!st.archivingIssueIds.has(item.issue.id)) { if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
act.archiveIssue(item.issue.id); } else if (item) {
} if (item.kind === "issue") {
} else { if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
const key = getWorkItemKey(item); } else {
if (!st.archivingNonIssueIds.has(key)) { const key = getWorkItemKey(item);
act.archiveNonIssue(key); if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
} }
} }
break; break;
} }
case "U": { case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault(); e.preventDefault();
const item = st.workItems[st.selectedIndex]; const { issue, item } = resolveNavEntry(st.selectedIndex);
if (item.kind === "issue") { if (issue) {
act.markUnreadIssue(item.issue.id); act.markUnreadIssue(issue.id);
} else { } else if (item) {
act.markNonIssueUnread(getWorkItemKey(item)); if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
else act.markNonIssueUnread(getWorkItemKey(item));
} }
break; break;
} }
case "r": { case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault(); e.preventDefault();
const item = st.workItems[st.selectedIndex]; const { issue, item } = resolveNavEntry(st.selectedIndex);
if (item.kind === "issue") { if (issue) {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) { if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id);
act.markRead(item.issue.id); } else if (item) {
} if (item.kind === "issue") {
} else { if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
const key = getWorkItemKey(item); } else {
if (!st.readItems.has(key)) { const key = getWorkItemKey(item);
act.markNonIssueRead(key); if (!st.readItems.has(key)) act.markNonIssueRead(key);
} }
} }
break; break;
} }
case "Enter": { case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault(); e.preventDefault();
const item = st.workItems[st.selectedIndex]; const { issue, item } = resolveNavEntry(st.selectedIndex);
if (item.kind === "issue") { if (issue) {
const pathId = item.issue.identifier ?? item.issue.id; const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState); const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState); rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState }); act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") { } else if (item) {
act.navigate(`/approvals/${item.approval.id}`); if (item.kind === "issue") {
} else if (item.kind === "failed_run") { const pathId = item.issue.identifier ?? item.issue.id;
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`); const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
}
} }
break; break;
} }
@ -1571,13 +1609,34 @@ export function Inbox() {
{showSeparatorBefore("work_items") && <Separator />} {showSeparatorBefore("work_items") && <Separator />}
<div> <div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card"> <div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{nestedWorkItems.flatMap((item, index) => { {(() => {
// Pre-compute flat nav index for each top-level item and child issue
let flatIdx = 0;
const topFlatIndex = new Map<number, number>();
const childFlatIndex = new Map<string, number>();
for (let ti = 0; ti < nestedWorkItems.length; ti++) {
topFlatIndex.set(ti, flatIdx);
flatIdx++;
const topItem = nestedWorkItems[ti];
if (topItem.kind === "issue") {
const children = childrenByIssueId.get(topItem.issue.id);
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id);
if (isExp) {
for (const c of children) {
childFlatIndex.set(c.id, flatIdx);
flatIdx++;
}
}
}
}
return nestedWorkItems.flatMap((item, index) => {
const navIdx = topFlatIndex.get(index) ?? index;
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div <div
key={`sel-${key}`} key={`sel-${key}`}
data-inbox-item data-inbox-item
className="relative" className="relative"
onClick={() => setSelectedIndex(index)} onClick={() => setSelectedIndex(navIdx)}
> >
{child} {child}
</div> </div>
@ -1599,7 +1658,7 @@ export function Inbox() {
</div>, </div>,
); );
} }
const isSelected = selectedIndex === index; const isSelected = selectedIndex === navIdx;
if (item.kind === "approval") { if (item.kind === "approval") {
const approvalKey = `approval:${item.approval.id}`; const approvalKey = `approval:${item.approval.id}`;
@ -1822,14 +1881,22 @@ export function Inbox() {
// Render children if expanded // Render children if expanded
if (isExpanded) { if (isExpanded) {
for (const child of childIssues) { for (const child of childIssues) {
const childRow = renderInboxIssue(child, 1, false); const cNavIdx = childFlatIndex.get(child.id) ?? -1;
const isChildSelected = selectedIndex === cNavIdx;
const childRow = renderInboxIssue(child, 1, isChildSelected);
const isChildArchiving = archivingIssueIds.has(child.id); const isChildArchiving = archivingIssueIds.has(child.id);
elements.push( elements.push(
<div key={`sel-issue:${child.id}`} data-inbox-item className="relative" style={{ paddingLeft: 16 }}> <div
key={`sel-issue:${child.id}`}
data-inbox-item
className="relative"
style={{ paddingLeft: 16 }}
onClick={() => setSelectedIndex(cNavIdx)}
>
{canArchiveFromTab ? ( {canArchiveFromTab ? (
<SwipeToArchive <SwipeToArchive
key={`issue:${child.id}`} key={`issue:${child.id}`}
selected={false} selected={isChildSelected}
disabled={isChildArchiving || archiveIssueMutation.isPending} disabled={isChildArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(child.id)} onArchive={() => archiveIssueMutation.mutate(child.id)}
> >
@ -1841,7 +1908,8 @@ export function Inbox() {
} }
} }
return elements; return elements;
})} });
})()}
</div> </div>
</div> </div>
</> </>