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:
@@ -11,7 +11,8 @@ use serde::Serialize;
|
||||
use specta::Type;
|
||||
|
||||
/// Simple greeting command for testing IPC
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub fn greet(name: &str) -> String {
|
||||
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.
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||
get_lore_status_with(&RealLoreCli)
|
||||
}
|
||||
@@ -105,7 +107,8 @@ pub struct BridgeStatus {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Bridge IO is blocking; run off the async executor
|
||||
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).
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub async fn sync_now() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| sync_now_inner(None))
|
||||
.await
|
||||
@@ -161,7 +165,8 @@ fn sync_now_inner(
|
||||
}
|
||||
|
||||
/// Trigger a full reconciliation pass.
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub async fn reconcile() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| reconcile_inner(None))
|
||||
.await
|
||||
@@ -193,7 +198,8 @@ pub struct CaptureResult {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
|
||||
.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.
|
||||
///
|
||||
/// 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> {
|
||||
tokio::task::spawn_blocking(read_frontend_state)
|
||||
.await
|
||||
@@ -221,7 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
/// Write frontend state to ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Uses atomic rename pattern to prevent corruption.
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
||||
.await
|
||||
@@ -230,7 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
}
|
||||
|
||||
/// Clear persisted frontend state.
|
||||
#[tauri::command]
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
pub async fn clear_state() -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(clear_frontend_state)
|
||||
.await
|
||||
|
||||
246
src/components/Inbox.tsx
Normal file
246
src/components/Inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
180
tests/components/Inbox.test.tsx
Normal file
180
tests/components/Inbox.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user