diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 72bcd2c0fe3..bf08d40b177 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -197,13 +197,49 @@ describe("AttentionQueueRow", () => { />, ); - const projectChip = container?.querySelector('[data-testid="attention-project-chip"]'); - expect(projectChip?.textContent).toBe("Alpha"); - expect(projectChip?.querySelector("button")).toBeNull(); + const projectMeta = container?.querySelector('[data-testid="attention-project-meta"]'); + expect(projectMeta?.textContent).toBe("Alpha"); + expect(projectMeta?.querySelector("button")).toBeNull(); + expect(projectMeta?.getAttribute("class")).not.toContain("border"); + expect(projectMeta?.getAttribute("class")).not.toContain("bg-"); expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull(); expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1); }); + it("places the timestamp beside the row menu without a clock icon", () => { + render( + , + ); + + const menu = container?.querySelector('[aria-label="Row actions"]'); + const menuArea = menu?.closest('[data-attention-menu="true"]'); + expect(menuArea?.textContent).not.toBe(""); + expect(container?.querySelector("svg.lucide-clock")).toBeNull(); + }); + + it("uses square row edges and can show a keyboard selection ring", () => { + render( + , + ); + + const row = container?.querySelector("[data-attention-row]"); + expect(row?.getAttribute("class")).not.toContain("rounded"); + expect(row?.getAttribute("class")).toContain("ring-ring"); + }); + it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => { render( ; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; + selected?: boolean; } export function AttentionQueueRow({ @@ -90,6 +88,7 @@ export function AttentionQueueRow({ agentMap, currentUserId, userLabelMap, + selected = false, }: AttentionQueueRowProps) { const meta = sourceMeta(item.sourceKind); const tone = attentionToneStyle(item); @@ -106,8 +105,6 @@ export function AttentionQueueRow({ // explicit Open button and never toggle on a stray click. const expandable = inline; const verbs = item.decisionVerbs.slice(0, 3); - const workspaceLabel = - item.workspace && item.workspace.name !== item.project?.name ? item.workspace.name : null; const activate = () => { if (expandable) onToggleExpand(); @@ -123,10 +120,14 @@ export function AttentionQueueRow({ return (
@@ -188,20 +189,6 @@ export function AttentionQueueRow({ {item.relatedIssue.identifier} )} - {isHidden && snoozedUntil ? ( - - - Reappears {reappearLabel(snoozedUntil)} - - ) : ( - - - {relativeTime(item.activityAt)} - - )}
@@ -210,10 +197,9 @@ export function AttentionQueueRow({

{detailLine}

- {(item.project || workspaceLabel) && ( + {item.project && (
- {item.project && } - {workspaceLabel && } +
)}
@@ -226,7 +212,17 @@ export function AttentionQueueRow({ {/* Controls: kept as siblings (not inside the clickable header) so they never toggle expand and stay valid interactive targets. */}
-
+
+ {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} /> ))}