Refine attention queue interactions

This commit is contained in:
Dotta committed 2026-07-10 20:08:18 +00:00
1 parent fdeb952610
commit 3a0550dc9c
6 files changed
+215 -50

No files matched your search

+39 -3
View File
@@ -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(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
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(
<AttentionQueueRow
item={buildItem()}
companyId="c1"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
selected
/>,
);
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(
<AttentionQueueRow
+26 -46
View File
@@ -2,16 +2,13 @@ import { useState, type KeyboardEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlarmClock,
Boxes,
ChevronDown,
ChevronRight,
Clock,
ExternalLink,
Loader2,
MoreHorizontal,
RotateCcw,
X,
type LucideIcon,
} from "lucide-react";
import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
@@ -76,6 +73,7 @@ interface AttentionQueueRowProps {
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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 (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg border border-border bg-card",
"transition-colors hover:border-border/80",
"relative flex flex-col overflow-hidden border border-border bg-card",
"motion-safe:transition-[opacity,transform,border-color,background-color] motion-safe:duration-200 motion-safe:ease-out hover:border-border/80",
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
selected && "border-ring ring-1 ring-ring",
)}
id={`attention-row-${item.id}`}
data-attention-row
data-attention-row-id={item.id}
data-attention-source={item.sourceKind}
data-attention-severity={item.severity}
>
@@ -188,20 +189,6 @@ export function AttentionQueueRow({
{item.relatedIssue.identifier}
</Link>
)}
{isHidden && snoozedUntil ? (
<span
className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
<AlarmClock className="h-3 w-3" />
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 text-(length:--text-nano) text-muted-foreground">
<Clock className="h-3 w-3" />
{relativeTime(item.activityAt)}
</span>
)}
</div>
<div className="mt-1">
@@ -210,10 +197,9 @@ export function AttentionQueueRow({
</span>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
{(item.project || workspaceLabel) && (
{item.project && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{item.project && <ProjectChip project={item.project} />}
{workspaceLabel && <Chip icon={Boxes} label={workspaceLabel} />}
<ProjectMeta project={item.project} />
</div>
)}
</div>
@@ -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. */}
<div className="flex shrink-0 self-stretch flex-col items-end justify-between gap-2" data-attention-controls="true">
<div className="flex justify-end" data-attention-menu="true">
<div className="flex items-center justify-end gap-1" data-attention-menu="true">
{isHidden && snoozedUntil ? (
<span
className="text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
)}
{!isHidden && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -300,7 +296,7 @@ export function AttentionQueueRow({
</div>
{inline && expanded && (
<div className="border-t border-border/60 bg-muted/20 px-4 py-3">
<div className="border-t border-border/60 bg-muted/20 px-4 py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200">
<InlineResolver
item={item}
companyId={companyId}
@@ -321,13 +317,13 @@ function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "def
return "outline";
}
/** Small passive pill used for the project identity. */
function ProjectChip({ project }: { project: NonNullable<AttentionItem["project"]> }) {
/** Inline project identity keeps useful context without a competing badge. */
function ProjectMeta({ project }: { project: NonNullable<AttentionItem["project"]> }) {
return (
<span
className="inline-flex max-w-(--sz-12rem) items-center gap-1.5 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground"
className="inline-flex max-w-(--sz-12rem) items-center gap-1.5 text-(length:--text-nano) text-muted-foreground"
title={project.name}
data-testid="attention-project-chip"
data-testid="attention-project-meta"
>
<ProjectTile color={project.color} icon={project.icon} size="xs" />
<span className="truncate">{project.name}</span>
@@ -335,22 +331,6 @@ function ProjectChip({ project }: { project: NonNullable<AttentionItem["project"
);
}
/** Small passive pill used for workspace context. */
function Chip({
icon: Icon,
label,
}: {
icon: LucideIcon;
label: string;
}) {
return (
<span className="inline-flex max-w-(--sz-12rem) items-center gap-1 rounded-sm border border-border/70 bg-muted/40 px-1.5 py-0.5 text-(length:--text-nano) text-muted-foreground">
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
);
}
/** Square screenshot thumbnails at the right of the description (plan §10). */
function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
const visible = images.slice(0, 3);
@@ -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: [
+32
View File
@@ -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 = `
+39
View File
@@ -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,
+68 -1
View File
@@ -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<string | null>(null);
const [selectedAttentionId, setSelectedAttentionId] = useState<string | null>(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}
/>
))}
</div>