diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2499fbc..14a977d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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 { 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 { // 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 { 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 { 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 { 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 Result, McError> { tokio::task::spawn_blocking(read_frontend_state) .await @@ -221,7 +228,8 @@ pub async fn read_state() -> Result, 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 diff --git a/src/components/Inbox.tsx b/src/components/Inbox.tsx new file mode 100644 index 0000000..092c44e --- /dev/null +++ b/src/components/Inbox.tsx @@ -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 = { + mention: "MENTION", + mr_feedback: "MR FEEDBACK", + review_request: "REVIEW", + assignment: "ASSIGNED", + manual: "TASK", +}; + +const TYPE_COLORS: Record = { + 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 ; + } + + return ( +
+

+ Inbox ({untriagedItems.length}) +

+ +
+ {untriagedItems.map((item) => ( + + ))} +
+
+ ); +} + +function InboxZero(): React.ReactElement { + return ( +
+
+ + + +
+

Inbox Zero

+

All caught up!

+
+ ); +} + +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(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 ( +
+ {/* Type badge */} + + {TYPE_LABELS[item.type]} + + + {/* Content */} +
+

{item.title}

+ {item.snippet && ( +

{item.snippet}

+ )} + {item.actor && ( + {item.actor} + )} +
+ + {/* Actions */} +
+ + +
+ + + {showDeferPicker && ( + setShowDeferPicker(false)} /> + )} +
+ + +
+
+ ); +} + +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 ( +
+ {DEFER_OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ec2072a..cfda6d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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"; diff --git a/tests/components/Inbox.test.tsx b/tests/components/Inbox.test.tsx new file mode 100644 index 0000000..c11147e --- /dev/null +++ b/tests/components/Inbox.test.tsx @@ -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(); + + const inboxItems = screen.getAllByTestId("inbox-item"); + expect(inboxItems).toHaveLength(2); + }); + + it("shows inbox zero state when empty", () => { + render(); + + 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(); + + expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument(); + }); + + it("accept moves item to queue", async () => { + const user = userEvent.setup(); + const onTriage = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + const archiveButtons = screen.getAllByRole("button", { name: /archive/i }); + await user.click(archiveButtons[0]); + + expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined); + }); + + it("displays item metadata", () => { + render(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // Focus the first inbox item + const firstItem = screen.getAllByTestId("inbox-item")[0]; + firstItem.focus(); + + await user.keyboard("x"); + + expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined); + }); + }); +});