+
+ {isHidden && snoozedUntil ? (
+
+ Reappears {reappearLabel(snoozedUntil)}
+
+ ) : (
+ {relativeTime(item.activityAt)}
+ )}
{!isHidden && (
@@ -300,7 +296,7 @@ export function AttentionQueueRow({
{inline && expanded && (
-
+
}) {
+/** Inline project identity keeps useful context without a competing badge. */
+function ProjectMeta({ project }: { project: NonNullable }) {
return (
{project.name}
@@ -335,22 +331,6 @@ function ProjectChip({ project }: { project: NonNullable
-
- {label}
-
- );
-}
-
/** Square screenshot thumbnails at the right of the description (plan §10). */
function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
const visible = images.slice(0, 3);
diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
index ea86e52d23b..9acd0289a51 100644
--- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx
+++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
@@ -49,6 +49,17 @@ const sections: ShortcutSection[] = [
{ keys: ["g", "c"], label: "Focus comment composer" },
],
},
+ {
+ title: "Decisions",
+ shortcuts: [
+ { keys: ["j"], label: "Move down" },
+ { keys: ["↓"], label: "Move down" },
+ { keys: ["k"], label: "Move up" },
+ { keys: ["↑"], label: "Move up" },
+ { keys: ["Enter"], label: "Open or close selected decision" },
+ { keys: ["x"], label: "Dismiss selected decision" },
+ ],
+ },
{
title: "Global",
shortcuts: [
diff --git a/ui/src/lib/keyboardShortcuts.test.ts b/ui/src/lib/keyboardShortcuts.test.ts
index 7bb1203037c..850f84e1b2c 100644
--- a/ui/src/lib/keyboardShortcuts.test.ts
+++ b/ui/src/lib/keyboardShortcuts.test.ts
@@ -6,6 +6,7 @@ import {
focusPageSearchShortcutTarget,
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
+ resolveAttentionQueueKeyAction,
resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction,
resolveInboxUndoArchiveKeyAction,
@@ -14,6 +15,37 @@ import {
} from "./keyboardShortcuts";
describe("keyboardShortcuts helpers", () => {
+ describe("resolveAttentionQueueKeyAction", () => {
+ const baseArgs = {
+ defaultPrevented: false,
+ metaKey: false,
+ ctrlKey: false,
+ altKey: false,
+ target: document.body,
+ hasOpenDialog: false,
+ hasSelection: true,
+ };
+
+ it.each([
+ ["j", "next"],
+ ["ArrowDown", "next"],
+ ["k", "previous"],
+ ["ArrowUp", "previous"],
+ ["Enter", "toggle"],
+ ["x", "dismiss"],
+ ] as const)("maps %s to %s", (key, expected) => {
+ expect(resolveAttentionQueueKeyAction({ ...baseArgs, key })).toBe(expected);
+ });
+
+ it("does not act while typing, dialog-bound, modified, or unselected", () => {
+ const input = document.createElement("input");
+ expect(resolveAttentionQueueKeyAction({ ...baseArgs, key: "j", target: input })).toBe("ignore");
+ expect(resolveAttentionQueueKeyAction({ ...baseArgs, key: "j", hasOpenDialog: true })).toBe("ignore");
+ expect(resolveAttentionQueueKeyAction({ ...baseArgs, key: "j", metaKey: true })).toBe("ignore");
+ expect(resolveAttentionQueueKeyAction({ ...baseArgs, key: "Enter", hasSelection: false })).toBe("ignore");
+ });
+ });
+
it("detects editable shortcut targets", () => {
const wrapper = document.createElement("div");
wrapper.innerHTML = `
diff --git a/ui/src/lib/keyboardShortcuts.ts b/ui/src/lib/keyboardShortcuts.ts
index 464bdb51389..5c2e81af14b 100644
--- a/ui/src/lib/keyboardShortcuts.ts
+++ b/ui/src/lib/keyboardShortcuts.ts
@@ -20,6 +20,7 @@ export type IssueDetailGoKeyAction =
| "focus_comment"
| "open_file_viewer"
| "disarm";
+export type AttentionQueueKeyAction = "ignore" | "next" | "previous" | "toggle" | "dismiss";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
@@ -85,6 +86,44 @@ export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEYS.has(key);
}
+export function resolveAttentionQueueKeyAction({
+ defaultPrevented,
+ key,
+ metaKey,
+ ctrlKey,
+ altKey,
+ target,
+ hasOpenDialog,
+ hasSelection,
+}: {
+ defaultPrevented: boolean;
+ key: string;
+ metaKey: boolean;
+ ctrlKey: boolean;
+ altKey: boolean;
+ target: EventTarget | null;
+ hasOpenDialog: boolean;
+ hasSelection: boolean;
+}): AttentionQueueKeyAction {
+ if (defaultPrevented || metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
+ if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
+
+ switch (key) {
+ case "j":
+ case "ArrowDown":
+ return "next";
+ case "k":
+ case "ArrowUp":
+ return "previous";
+ case "Enter":
+ return hasSelection ? "toggle" : "ignore";
+ case "x":
+ return hasSelection ? "dismiss" : "ignore";
+ default:
+ return "ignore";
+ }
+}
+
export function resolveInboxQuickArchiveKeyAction({
armed,
defaultPrevented,
diff --git a/ui/src/pages/WhatNeedsMe.tsx b/ui/src/pages/WhatNeedsMe.tsx
index 84b574fc1d7..dc53933bbf6 100644
--- a/ui/src/pages/WhatNeedsMe.tsx
+++ b/ui/src/pages/WhatNeedsMe.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpDown, Check, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
+import { useNavigate } from "@/lib/router";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -35,6 +36,7 @@ import {
type AttentionSortOrder,
} from "../lib/attention";
import { cn } from "../lib/utils";
+import { hasBlockingShortcutDialog, resolveAttentionQueueKeyAction } from "../lib/keyboardShortcuts";
import { PageSkeleton } from "../components/PageSkeleton";
import { AttentionQueueRow } from "../components/AttentionQueueRow";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
@@ -53,6 +55,7 @@ export function WhatNeedsMe() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [expandedId, setExpandedId] = useState(null);
+ const [selectedAttentionId, setSelectedAttentionId] = useState(null);
const [autoExpandDone, setAutoExpandDone] = useState(false);
// Toolbar preferences (persisted to localStorage, Inbox pattern).
@@ -69,6 +72,7 @@ export function WhatNeedsMe() {
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
const { pushToast } = useToastActions();
+ const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "Decisions" }]);
@@ -153,6 +157,21 @@ export function WhatNeedsMe() {
}, [activeItems, filters, sortOrder, groupBy]);
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
+ const keyboardItems = useMemo(
+ () => groups.filter((group) => group.label === null || !collapsedGroupKeys.has(group.key)).flatMap((group) => group.items),
+ [collapsedGroupKeys, groups],
+ );
+
+ useEffect(() => {
+ if (selectedAttentionId && !keyboardItems.some((item) => item.id === selectedAttentionId)) {
+ setSelectedAttentionId(null);
+ }
+ }, [keyboardItems, selectedAttentionId]);
+
+ useEffect(() => {
+ if (!selectedAttentionId) return;
+ document.getElementById(`attention-row-${selectedAttentionId}`)?.scrollIntoView({ block: "nearest" });
+ }, [selectedAttentionId]);
// Auto-expand the topmost inline-capable decision, once.
useEffect(() => {
@@ -217,6 +236,50 @@ export function WhatNeedsMe() {
setPendingRestore((prev) => new Set(prev).add(item.id));
restore(item.dismissalKey);
};
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const action = resolveAttentionQueueKeyAction({
+ defaultPrevented: event.defaultPrevented,
+ key: event.key,
+ metaKey: event.metaKey,
+ ctrlKey: event.ctrlKey,
+ altKey: event.altKey,
+ target: event.target,
+ hasOpenDialog: hasBlockingShortcutDialog(document),
+ hasSelection: selectedAttentionId !== null,
+ });
+ if (action === "ignore" || keyboardItems.length === 0) return;
+
+ if (action === "next" || action === "previous") {
+ event.preventDefault();
+ const currentIndex = selectedAttentionId ? keyboardItems.findIndex((item) => item.id === selectedAttentionId) : -1;
+ const offset = action === "next" ? 1 : -1;
+ const nextIndex = currentIndex < 0
+ ? action === "next"
+ ? 0
+ : keyboardItems.length - 1
+ : (currentIndex + offset + keyboardItems.length) % keyboardItems.length;
+ setSelectedAttentionId(keyboardItems[nextIndex]?.id ?? null);
+ return;
+ }
+
+ const selectedItem = keyboardItems.find((item) => item.id === selectedAttentionId);
+ if (!selectedItem) return;
+ event.preventDefault();
+
+ if (action === "dismiss") {
+ handleDismiss(selectedItem);
+ } else if (isInlineResolvable(selectedItem)) {
+ setExpandedId((previous) => (previous === selectedItem.id ? null : selectedItem.id));
+ } else if (selectedItem.subject.href) {
+ navigate(selectedItem.subject.href);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [keyboardItems, navigate, selectedAttentionId]);
const activeFilterCount = countActiveAttentionFilters(filters);
if (!selectedCompanyId) {
@@ -363,11 +426,15 @@ export function WhatNeedsMe() {
item={item}
companyId={selectedCompanyId}
expanded={expandedId === item.id}
- onToggleExpand={() => setExpandedId((prev) => (prev === item.id ? null : item.id))}
+ onToggleExpand={() => {
+ setSelectedAttentionId(item.id);
+ setExpandedId((prev) => (prev === item.id ? null : item.id));
+ }}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
agentMap={agentMap}
currentUserId={currentUserId}
+ selected={selectedAttentionId === item.id}
/>
))}