mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
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:
parent
d3e66c789e
commit
58ae23aa2c
1 changed files with 115 additions and 47 deletions
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue