feat: implement Inbox view with triage actions

- Add InboxItem types and TriageAction/DeferDuration types
- Create Inbox component with:
  - Accept/Defer/Archive actions for each item
  - Keyboard shortcuts (A/D/X) for fast triage
  - Defer duration picker popup
  - Inbox Zero celebration state
  - Type-specific badges with colors
- Add comprehensive tests for all functionality

Closes bd-qvc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:08:54 -05:00
parent 087b588d71
commit 175c1994fc
4 changed files with 477 additions and 9 deletions

246
src/components/Inbox.tsx Normal file
View File

@@ -0,0 +1,246 @@
/**
* Inbox -- triage incoming work items.
*
* Shows untriaged items with Accept/Defer/Archive actions.
* Achievable inbox zero is the goal.
*/
import { useState, useCallback, useRef, useEffect } from "react";
import type { InboxItem, InboxItemType, TriageAction, DeferDuration } from "@/lib/types";
interface InboxProps {
items: InboxItem[];
onTriage: (id: string, action: TriageAction, duration?: DeferDuration) => void;
}
const TYPE_LABELS: Record<InboxItemType, string> = {
mention: "MENTION",
mr_feedback: "MR FEEDBACK",
review_request: "REVIEW",
assignment: "ASSIGNED",
manual: "TASK",
};
const TYPE_COLORS: Record<InboxItemType, string> = {
mention: "bg-blue-900/50 text-blue-400",
mr_feedback: "bg-purple-900/50 text-purple-400",
review_request: "bg-amber-900/50 text-amber-400",
assignment: "bg-green-900/50 text-green-400",
manual: "bg-zinc-800 text-zinc-400",
};
const DEFER_OPTIONS: { label: string; value: DeferDuration }[] = [
{ label: "1 hour", value: "1h" },
{ label: "3 hours", value: "3h" },
{ label: "Tomorrow", value: "tomorrow" },
{ label: "Next week", value: "next_week" },
];
export function Inbox({ items, onTriage }: InboxProps): React.ReactElement {
const untriagedItems = items.filter((i) => !i.triaged);
if (untriagedItems.length === 0) {
return <InboxZero />;
}
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-zinc-200">
Inbox ({untriagedItems.length})
</h2>
<div className="flex flex-col gap-2">
{untriagedItems.map((item) => (
<InboxItemRow key={item.id} item={item} onTriage={onTriage} />
))}
</div>
</div>
);
}
function InboxZero(): React.ReactElement {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<svg
className="h-8 w-8 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-zinc-200">Inbox Zero</h2>
<p className="text-zinc-500">All caught up!</p>
</div>
);
}
interface InboxItemRowProps {
item: InboxItem;
onTriage: (id: string, action: TriageAction, duration?: DeferDuration) => void;
}
function InboxItemRow({ item, onTriage }: InboxItemRowProps): React.ReactElement {
const [showDeferPicker, setShowDeferPicker] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const handleAccept = useCallback(() => {
onTriage(item.id, "accept", undefined);
}, [item.id, onTriage]);
const handleDefer = useCallback(
(duration: DeferDuration) => {
onTriage(item.id, "defer", duration);
setShowDeferPicker(false);
},
[item.id, onTriage]
);
const handleArchive = useCallback(() => {
onTriage(item.id, "archive", undefined);
}, [item.id, onTriage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key.toLowerCase()) {
case "a":
e.preventDefault();
handleAccept();
break;
case "d":
e.preventDefault();
setShowDeferPicker(true);
break;
case "x":
e.preventDefault();
handleArchive();
break;
}
},
[handleAccept, handleArchive]
);
// Close defer picker when clicking outside
useEffect(() => {
if (!showDeferPicker) return;
const handleClickOutside = (e: MouseEvent) => {
if (itemRef.current && !itemRef.current.contains(e.target as Node)) {
setShowDeferPicker(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showDeferPicker]);
return (
<div
ref={itemRef}
data-testid="inbox-item"
tabIndex={0}
onKeyDown={handleKeyDown}
className="relative flex items-start gap-4 rounded-lg border border-zinc-800 bg-surface-raised p-4 transition-colors hover:border-zinc-700 hover:bg-surface-overlay/50 focus:border-zinc-600 focus:outline-none"
>
{/* Type badge */}
<span
className={`flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-bold tracking-wider ${TYPE_COLORS[item.type]}`}
>
{TYPE_LABELS[item.type]}
</span>
{/* Content */}
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium text-zinc-200">{item.title}</h3>
{item.snippet && (
<p className="mt-1 truncate text-xs text-zinc-500">{item.snippet}</p>
)}
{item.actor && (
<span className="mt-1 inline-block text-xs text-zinc-500">{item.actor}</span>
)}
</div>
{/* Actions */}
<div className="flex flex-shrink-0 gap-2">
<button
type="button"
onClick={handleAccept}
className="rounded bg-green-600/20 px-3 py-1.5 text-xs font-medium text-green-400 transition-colors hover:bg-green-600/30"
>
Accept
<kbd className="ml-1.5 rounded bg-green-600/20 px-1 text-[10px] text-green-500">
A
</kbd>
</button>
<div className="relative">
<button
type="button"
onClick={() => setShowDeferPicker(!showDeferPicker)}
className="rounded bg-amber-600/20 px-3 py-1.5 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-600/30"
>
Defer
<kbd className="ml-1.5 rounded bg-amber-600/20 px-1 text-[10px] text-amber-500">
D
</kbd>
</button>
{showDeferPicker && (
<DeferPicker onSelect={handleDefer} onClose={() => setShowDeferPicker(false)} />
)}
</div>
<button
type="button"
onClick={handleArchive}
className="rounded bg-zinc-700/50 px-3 py-1.5 text-xs font-medium text-zinc-400 transition-colors hover:bg-zinc-700/70"
>
Archive
<kbd className="ml-1.5 rounded bg-zinc-700/50 px-1 text-[10px] text-zinc-500">
X
</kbd>
</button>
</div>
</div>
);
}
interface DeferPickerProps {
onSelect: (duration: DeferDuration) => void;
onClose: () => void;
}
function DeferPicker({ onSelect, onClose }: DeferPickerProps): React.ReactElement {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
return (
<div
role="menu"
onKeyDown={handleKeyDown}
className="absolute right-0 top-full z-10 mt-1 min-w-[120px] rounded-lg border border-zinc-700 bg-surface-raised p-1 shadow-lg"
>
{DEFER_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
role="menuitem"
onClick={() => onSelect(option.value)}
className="block w-full rounded px-3 py-1.5 text-left text-xs text-zinc-300 transition-colors hover:bg-zinc-700/50"
>
{option.label}
</button>
))}
</div>
);
}

View File

@@ -118,6 +118,39 @@ export interface DecisionEntry {
/** Staleness level derived from item age */
export type Staleness = "fresh" | "normal" | "amber" | "urgent";
// -- Inbox types --
/** Type of work item in the inbox */
export type InboxItemType = "mention" | "mr_feedback" | "review_request" | "assignment" | "manual";
/** A work item awaiting triage in the inbox */
export interface InboxItem {
/** Unique identifier */
id: string;
/** Human-readable title */
title: string;
/** Type of inbox item */
type: InboxItemType;
/** Whether this item has been triaged */
triaged: boolean;
/** When the item was created/arrived */
createdAt: string;
/** Optional snippet/preview */
snippet?: string;
/** Source project */
project?: string;
/** Web URL for opening in browser */
url?: string;
/** Who triggered this item (e.g., commenter name) */
actor?: string;
}
/** Triage action the user can take on an inbox item */
export type TriageAction = "accept" | "defer" | "archive";
/** Duration options for deferring an item */
export type DeferDuration = "1h" | "3h" | "tomorrow" | "next_week";
/** Compute staleness from an ISO timestamp */
export function computeStaleness(updatedAt: string | null): Staleness {
if (!updatedAt) return "normal";