Refine mine inbox shortcut behavior

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 16:45:44 -05:00
parent 7d81e4cb2a
commit 403aeff7f6
5 changed files with 39 additions and 19 deletions

View file

@ -136,8 +136,8 @@ describe("SwipeToArchive", () => {
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
expect(surface).not.toBeNull(); expect(surface).not.toBeNull();
expect(surface?.style.backgroundColor).toBe("hsl(var(--primary) / 0.06)"); expect(surface?.style.backgroundColor).toBe("hsl(var(--muted))");
expect(surface?.style.boxShadow).toBe("inset 3px 0 0 hsl(var(--primary))"); expect(surface?.style.boxShadow).toBe("");
act(() => { act(() => {
root.unmount(); root.unmount();

View file

@ -155,8 +155,7 @@ export function SwipeToArchive({
style={{ style={{
transform: `translate3d(${offsetX}px, 0, 0)`, transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out", transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? "hsl(var(--primary) / 0.06)" : undefined, backgroundColor: selected ? "hsl(var(--muted))" : undefined,
boxShadow: selected ? "inset 3px 0 0 hsl(var(--primary))" : undefined,
}} }}
> >
{children} {children}

View file

@ -6,6 +6,7 @@ import {
computeInboxBadgeData, computeInboxBadgeData,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
isMineInboxTab, isMineInboxTab,
@ -411,9 +412,15 @@ describe("inbox helpers", () => {
}); });
it("anchors Mine selection to the first available inbox row", () => { it("anchors Mine selection to the first available inbox row", () => {
expect(resolveInboxSelectionIndex(-1, 3, true)).toBe(0); expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
expect(resolveInboxSelectionIndex(-1, 3, false)).toBe(-1); expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
expect(resolveInboxSelectionIndex(5, 3, true)).toBe(2); expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
expect(resolveInboxSelectionIndex(1, 0, true)).toBe(-1); });
it("selects the first row only after keyboard navigation starts", () => {
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
}); });
}); });

View file

@ -105,13 +105,24 @@ export function isMineInboxTab(tab: InboxTab): boolean {
export function resolveInboxSelectionIndex( export function resolveInboxSelectionIndex(
previousIndex: number, previousIndex: number,
itemCount: number, itemCount: number,
canSelectItems: boolean,
): number { ): number {
if (itemCount === 0) return -1; if (itemCount === 0) return -1;
if (previousIndex < 0) return canSelectItems ? 0 : -1; if (previousIndex < 0) return -1;
return Math.min(previousIndex, itemCount - 1); return Math.min(previousIndex, itemCount - 1);
} }
export function getInboxKeyboardSelectionIndex(
previousIndex: number,
itemCount: number,
direction: "next" | "previous",
): number {
if (itemCount === 0) return -1;
if (previousIndex < 0) return 0;
return direction === "next"
? Math.min(previousIndex + 1, itemCount - 1)
: Math.max(previousIndex - 1, 0);
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort( const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),

View file

@ -47,6 +47,7 @@ import {
ACTIONABLE_APPROVAL_STATUSES, ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
isMineInboxTab, isMineInboxTab,
@ -929,10 +930,10 @@ export function Inbox() {
return `join:${item.joinRequest.id}`; return `join:${item.joinRequest.id}`;
}, []); }, []);
// Keep Mine anchored to a real row so keyboard navigation always lands on an item. // Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => { useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length, canArchiveFromTab)); setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
}, [canArchiveFromTab, workItemsToRender.length]); }, [workItemsToRender.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({
@ -976,13 +977,15 @@ export function Inbox() {
// Keyboard shortcuts (mail-client style) — single stable listener using refs // Keyboard shortcuts (mail-client style) — single stable listener using refs
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
// Don't capture when typing in inputs/textareas or with modifier keys // Don't capture when typing in inputs/textareas or with modifier keys
const target = e.target as HTMLElement; const target = e.target;
if ( if (
target.tagName === "INPUT" || !(target instanceof HTMLElement) ||
target.tagName === "TEXTAREA" || target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.tagName === "SELECT" ||
target.isContentEditable || target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey || e.metaKey ||
e.ctrlKey || e.ctrlKey ||
e.altKey e.altKey
@ -1002,12 +1005,12 @@ export function Inbox() {
switch (e.key) { switch (e.key) {
case "j": { case "j": {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1)); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
break; break;
} }
case "k": { case "k": {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0)); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
break; break;
} }
case "a": case "a":