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

View File

@@ -11,7 +11,8 @@ use serde::Serialize;
use specta::Type; use specta::Type;
/// Simple greeting command for testing IPC /// Simple greeting command for testing IPC
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub fn greet(name: &str) -> String { pub fn greet(name: &str) -> String {
format!("Hello, {}! Welcome to Mission Control.", name) format!("Hello, {}! Welcome to Mission Control.", name)
} }
@@ -34,7 +35,8 @@ pub struct LoreSummaryStatus {
} }
/// Get the current status of lore integration by calling the real CLI. /// Get the current status of lore integration by calling the real CLI.
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn get_lore_status() -> Result<LoreStatus, McError> { pub async fn get_lore_status() -> Result<LoreStatus, McError> {
get_lore_status_with(&RealLoreCli) get_lore_status_with(&RealLoreCli)
} }
@@ -105,7 +107,8 @@ pub struct BridgeStatus {
} }
/// Get the current status of the bridge (mapping counts, sync times). /// Get the current status of the bridge (mapping counts, sync times).
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> { pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
// Bridge IO is blocking; run off the async executor // Bridge IO is blocking; run off the async executor
tokio::task::spawn_blocking(|| get_bridge_status_inner(None)) tokio::task::spawn_blocking(|| get_bridge_status_inner(None))
@@ -137,7 +140,8 @@ fn get_bridge_status_inner(
} }
/// Trigger an incremental sync (process since_last_check events). /// Trigger an incremental sync (process since_last_check events).
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn sync_now() -> Result<SyncResult, McError> { pub async fn sync_now() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| sync_now_inner(None)) tokio::task::spawn_blocking(|| sync_now_inner(None))
.await .await
@@ -161,7 +165,8 @@ fn sync_now_inner(
} }
/// Trigger a full reconciliation pass. /// Trigger a full reconciliation pass.
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn reconcile() -> Result<SyncResult, McError> { pub async fn reconcile() -> Result<SyncResult, McError> {
tokio::task::spawn_blocking(|| reconcile_inner(None)) tokio::task::spawn_blocking(|| reconcile_inner(None))
.await .await
@@ -193,7 +198,8 @@ pub struct CaptureResult {
} }
/// Quick-capture a thought as a new bead. /// Quick-capture a thought as a new bead.
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> { pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title)) tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
.await .await
@@ -210,7 +216,8 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult,
/// Read persisted frontend state from ~/.local/share/mc/state.json. /// Read persisted frontend state from ~/.local/share/mc/state.json.
/// ///
/// Returns null if no state exists (first run). /// Returns null if no state exists (first run).
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn read_state() -> Result<Option<FrontendState>, McError> { pub async fn read_state() -> Result<Option<FrontendState>, McError> {
tokio::task::spawn_blocking(read_frontend_state) tokio::task::spawn_blocking(read_frontend_state)
.await .await
@@ -221,7 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
/// Write frontend state to ~/.local/share/mc/state.json. /// Write frontend state to ~/.local/share/mc/state.json.
/// ///
/// Uses atomic rename pattern to prevent corruption. /// Uses atomic rename pattern to prevent corruption.
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn write_state(state: FrontendState) -> Result<(), McError> { pub async fn write_state(state: FrontendState) -> Result<(), McError> {
tokio::task::spawn_blocking(move || write_frontend_state(&state)) tokio::task::spawn_blocking(move || write_frontend_state(&state))
.await .await
@@ -230,7 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
} }
/// Clear persisted frontend state. /// Clear persisted frontend state.
#[tauri::command] #[tauri_specta::command]
#[specta(crate = "specta")]
pub async fn clear_state() -> Result<(), McError> { pub async fn clear_state() -> Result<(), McError> {
tokio::task::spawn_blocking(clear_frontend_state) tokio::task::spawn_blocking(clear_frontend_state)
.await .await

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 */ /** Staleness level derived from item age */
export type Staleness = "fresh" | "normal" | "amber" | "urgent"; 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 */ /** Compute staleness from an ISO timestamp */
export function computeStaleness(updatedAt: string | null): Staleness { export function computeStaleness(updatedAt: string | null): Staleness {
if (!updatedAt) return "normal"; if (!updatedAt) return "normal";

View File

@@ -0,0 +1,180 @@
/**
* Tests for Inbox component.
*
* TDD: These tests define the expected behavior before implementation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Inbox } from "@/components/Inbox";
import type { InboxItem, TriageAction, DeferDuration } from "@/lib/types";
const mockNewItems: InboxItem[] = [
{
id: "1",
type: "mention",
title: "You were mentioned in #312",
triaged: false,
createdAt: "2026-02-26T10:00:00Z",
snippet: "@user can you look at this?",
actor: "alice",
},
{
id: "2",
type: "mr_feedback",
title: "Comment on MR !847",
triaged: false,
createdAt: "2026-02-26T09:00:00Z",
snippet: "This needs some refactoring",
actor: "bob",
},
];
describe("Inbox", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows only untriaged items", () => {
const items: InboxItem[] = [
...mockNewItems,
{
id: "3",
type: "mention",
title: "Already triaged",
triaged: true,
createdAt: "2026-02-26T08:00:00Z",
},
];
render(<Inbox items={items} onTriage={vi.fn()} />);
const inboxItems = screen.getAllByTestId("inbox-item");
expect(inboxItems).toHaveLength(2);
});
it("shows inbox zero state when empty", () => {
render(<Inbox items={[]} onTriage={vi.fn()} />);
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
expect(screen.getByText(/All caught up/i)).toBeInTheDocument();
});
it("shows inbox zero when all items are triaged", () => {
const items: InboxItem[] = [
{
id: "1",
type: "mention",
title: "Triaged item",
triaged: true,
createdAt: "2026-02-26T10:00:00Z",
},
];
render(<Inbox items={items} onTriage={vi.fn()} />);
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
});
it("accept moves item to queue", async () => {
const user = userEvent.setup();
const onTriage = vi.fn();
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
await user.click(acceptButtons[0]);
expect(onTriage).toHaveBeenCalledWith("1", "accept", undefined);
});
it("defer shows duration picker", async () => {
const user = userEvent.setup();
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
const deferButtons = screen.getAllByRole("button", { name: /defer/i });
await user.click(deferButtons[0]);
// Defer picker should show duration options
expect(screen.getByText("1 hour")).toBeInTheDocument();
expect(screen.getByText("Tomorrow")).toBeInTheDocument();
});
it("defer with duration calls onTriage", async () => {
const user = userEvent.setup();
const onTriage = vi.fn();
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
const deferButtons = screen.getAllByRole("button", { name: /defer/i });
await user.click(deferButtons[0]);
await user.click(screen.getByText("1 hour"));
expect(onTriage).toHaveBeenCalledWith("1", "defer", "1h");
});
it("archive removes item from view", async () => {
const user = userEvent.setup();
const onTriage = vi.fn();
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
const archiveButtons = screen.getAllByRole("button", { name: /archive/i });
await user.click(archiveButtons[0]);
expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined);
});
it("displays item metadata", () => {
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
expect(screen.getByText("You were mentioned in #312")).toBeInTheDocument();
expect(screen.getByText("@user can you look at this?")).toBeInTheDocument();
expect(screen.getByText("alice")).toBeInTheDocument();
});
it("displays item count in header", () => {
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
expect(screen.getByText(/Inbox \(2\)/)).toBeInTheDocument();
});
describe("keyboard shortcuts", () => {
it("pressing 'a' on focused item triggers accept", async () => {
const user = userEvent.setup();
const onTriage = vi.fn();
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
// Focus the first inbox item
const firstItem = screen.getAllByTestId("inbox-item")[0];
firstItem.focus();
await user.keyboard("a");
expect(onTriage).toHaveBeenCalledWith("1", "accept", undefined);
});
it("pressing 'd' on focused item opens defer picker", async () => {
const user = userEvent.setup();
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
// Focus the first inbox item
const firstItem = screen.getAllByTestId("inbox-item")[0];
firstItem.focus();
await user.keyboard("d");
expect(screen.getByText("1 hour")).toBeInTheDocument();
});
it("pressing 'x' on focused item triggers archive", async () => {
const user = userEvent.setup();
const onTriage = vi.fn();
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
// Focus the first inbox item
const firstItem = screen.getAllByTestId("inbox-item")[0];
firstItem.focus();
await user.keyboard("x");
expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined);
});
});
});