mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Refine attention queue interactions
This commit is contained in:
1 parent
fdeb952610
commit
3a0550dc9c
6 files changed
+215
-50
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user