From d4b8a4baea74ef1bae6e334f737ecd8431fd7376 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 11:00:15 -0500 Subject: [PATCH] feat(bd-318): implement QueueView container with filtering and batch support QueueView now supports: - Filtering items via CommandPalette (Cmd+K) - Hide snoozed items by default (showSnoozed prop) - Show snooze count indicator when items are hidden - Support batch mode entry for sections with 2+ items - Filter by type prop for programmatic filtering Added snoozedUntil field to FocusItem type and updated fixtures. Co-Authored-By: Claude Opus 4.5 --- src/components/QueueView.tsx | 188 ++++++++++++--- src/lib/types.ts | 92 +++----- tests/components/QueueView.test.tsx | 340 +++++++++++++++++++++++++++- tests/helpers/fixtures.ts | 19 +- 4 files changed, 541 insertions(+), 98 deletions(-) diff --git a/src/components/QueueView.tsx b/src/components/QueueView.tsx index 8e7d3c2..75b55cb 100644 --- a/src/components/QueueView.tsx +++ b/src/components/QueueView.tsx @@ -3,16 +3,29 @@ * * Groups items into sections (Reviews, Issues, Authored MRs, Tasks), * shows counts, and allows clicking to set focus. + * + * Features: + * - Filter items via CommandPalette (Cmd+K) + * - Hide snoozed items by default + * - Support batch mode entry for sections with 2+ items */ +import { useCallback, useEffect, useMemo, useState } from "react"; import { motion } from "framer-motion"; import { useFocusStore } from "@/stores/focus-store"; import { QueueItem } from "./QueueItem"; +import { CommandPalette, type FilterCriteria } from "./CommandPalette"; import type { FocusItem, FocusItemType } from "@/lib/types"; -interface QueueViewProps { +export interface QueueViewProps { onSetFocus: (id: string) => void; onSwitchToFocus: () => void; + /** Callback to start batch mode with the given items and label */ + onStartBatch?: (items: FocusItem[], label: string) => void; + /** Show snoozed items (default: false) */ + showSnoozed?: boolean; + /** Filter to a specific type */ + filterType?: FocusItemType; } interface Section { @@ -28,6 +41,12 @@ const SECTION_ORDER: { type: FocusItemType; label: string }[] = [ { type: "manual", label: "TASKS" }, ]; +/** Check if an item is currently snoozed (snooze time in the future) */ +function isSnoozed(item: FocusItem): boolean { + if (!item.snoozedUntil) return false; + return new Date(item.snoozedUntil).getTime() > Date.now(); +} + function groupByType(items: FocusItem[]): Section[] { return SECTION_ORDER.map(({ type, label }) => ({ type, @@ -39,14 +58,78 @@ function groupByType(items: FocusItem[]): Section[] { export function QueueView({ onSetFocus, onSwitchToFocus, + onStartBatch, + showSnoozed = false, + filterType, }: QueueViewProps): React.ReactElement { const current = useFocusStore((s) => s.current); const queue = useFocusStore((s) => s.queue); - // Combine current + queue for the full list - const allItems = current ? [current, ...queue] : [...queue]; + // Command palette state + const [isPaletteOpen, setIsPaletteOpen] = useState(false); + const [activeFilter, setActiveFilter] = useState({}); - if (allItems.length === 0) { + // Combine current + queue for the full list + const allItems = useMemo(() => { + return current ? [current, ...queue] : [...queue]; + }, [current, queue]); + + // Apply snooze filtering + const visibleItems = useMemo(() => { + return allItems.filter((item) => showSnoozed || !isSnoozed(item)); + }, [allItems, showSnoozed]); + + // Count snoozed items for the indicator + const snoozedCount = useMemo(() => { + return allItems.filter(isSnoozed).length; + }, [allItems]); + + // Apply type filter (from props or command palette) + const effectiveFilterType = filterType ?? activeFilter.type; + const filteredItems = useMemo(() => { + if (!effectiveFilterType) return visibleItems; + return visibleItems.filter((item) => item.type === effectiveFilterType); + }, [visibleItems, effectiveFilterType]); + + // Handle Cmd+K to open palette + useEffect(() => { + function handleKeyDown(e: KeyboardEvent): void { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setIsPaletteOpen(true); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + const handleFilter = useCallback((criteria: FilterCriteria) => { + setActiveFilter(criteria); + }, []); + + const handlePaletteSelect = useCallback( + (itemId: string) => { + onSetFocus(itemId); + onSwitchToFocus(); + }, + [onSetFocus, onSwitchToFocus] + ); + + const handleClosePalette = useCallback(() => { + setIsPaletteOpen(false); + }, []); + + const handleStartBatch = useCallback( + (items: FocusItem[], label: string) => { + if (onStartBatch) { + onStartBatch(items, label); + } + }, + [onStartBatch] + ); + + if (filteredItems.length === 0 && allItems.length === 0) { return (

No items in the queue

@@ -54,13 +137,26 @@ export function QueueView({ ); } - const sections = groupByType(allItems); + const sections = groupByType(filteredItems); + const isFiltered = effectiveFilterType !== undefined; return (
{/* Header */}
-

Queue

+
+

Queue

+ {isFiltered && ( + + Filtered + + )} + {!showSnoozed && snoozedCount > 0 && ( + + {snoozedCount} snoozed + + )} +
+ )} +
+
+ {section.items.map((item) => ( + { + onSetFocus(id); + onSwitchToFocus(); + }} + isFocused={current?.id === item.id} + /> + ))} +
+ + )) + )}
+ + {/* Command Palette */} +
); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 40b1927..60e6b49 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,65 +1,26 @@ /** - * TypeScript types mirroring the Rust backend data structures. + * TypeScript types for Mission Control. * - * These are used by the IPC layer and components to maintain - * type safety across the Tauri boundary. + * IPC types are auto-generated by tauri-specta and re-exported from bindings. + * Frontend-only types are defined here. */ -// -- Backend response types (match Rust structs in commands/mod.rs) -- +// -- Re-export IPC types from generated bindings -- +export type { + BridgeStatus, + CaptureResult, + JsonValue, + LoreStatus, + LoreSummaryStatus, + McError, + McErrorCode, + SyncResult, + Result, +} from "./bindings"; -export interface LoreStatus { - last_sync: string | null; - is_healthy: boolean; - message: string; - summary: LoreSummaryStatus | null; -} +// -- Type guards for IPC types -- -export interface LoreSummaryStatus { - open_issues: number; - authored_mrs: number; - reviewing_mrs: number; -} - -export interface BridgeStatus { - mapping_count: number; - pending_count: number; - suspect_count: number; - last_sync: string | null; - last_reconciliation: string | null; -} - -export interface SyncResult { - created: number; - closed: number; - skipped: number; - /** Number of suspect_orphan flags cleared (item reappeared) */ - healed: number; - /** Error messages from non-fatal errors during sync */ - errors: string[]; -} - -// -- Structured error types (match Rust error.rs) -- - -/** Error codes for programmatic handling */ -export type McErrorCode = - | "LORE_UNAVAILABLE" - | "LORE_UNHEALTHY" - | "LORE_FETCH_FAILED" - | "BRIDGE_LOCKED" - | "BRIDGE_MAP_CORRUPTED" - | "BRIDGE_SYNC_FAILED" - | "BEADS_UNAVAILABLE" - | "BEADS_CREATE_FAILED" - | "BEADS_CLOSE_FAILED" - | "IO_ERROR" - | "INTERNAL_ERROR"; - -/** Structured error from Tauri IPC commands */ -export interface McError { - code: McErrorCode; - message: string; - recoverable: boolean; -} +import type { McError } from "./bindings"; /** Type guard to check if an error is a structured McError */ export function isMcError(err: unknown): err is McError { @@ -72,11 +33,6 @@ export function isMcError(err: unknown): err is McError { ); } -/** Result from the quick_capture command */ -export interface CaptureResult { - bead_id: string; -} - // -- Frontend-only types -- /** The type of work item surfaced in the Focus View */ @@ -102,10 +58,18 @@ export interface FocusItem { contextQuote: string | null; /** Who is requesting attention */ requestedBy: string | null; + /** ISO timestamp when snooze expires (item hidden until then) */ + snoozedUntil: string | null; } /** Action the user takes on a focused item */ -export type FocusAction = "start" | "defer_1h" | "defer_tomorrow" | "skip"; +export type FocusAction = + | "start" + | "defer_1h" + | "defer_3h" + | "defer_tomorrow" + | "defer_next_week" + | "skip"; /** An entry in the decision log */ export interface DecisionEntry { @@ -143,6 +107,10 @@ export interface InboxItem { url?: string; /** Who triggered this item (e.g., commenter name) */ actor?: string; + /** Whether this item has been archived */ + archived?: boolean; + /** ISO timestamp when snooze expires (item hidden until then) */ + snoozedUntil?: string; } /** Triage action the user can take on an inbox item */ diff --git a/tests/components/QueueView.test.tsx b/tests/components/QueueView.test.tsx index 93c765f..80fdf1e 100644 --- a/tests/components/QueueView.test.tsx +++ b/tests/components/QueueView.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueueView } from "@/components/QueueView"; import { useFocusStore } from "@/stores/focus-store"; +import { useBatchStore } from "@/stores/batch-store"; import { makeFocusItem } from "../helpers/fixtures"; describe("QueueView", () => { @@ -13,6 +14,14 @@ describe("QueueView", () => { isLoading: false, error: null, }); + useBatchStore.setState({ + isActive: false, + batchLabel: "", + items: [], + statuses: [], + currentIndex: 0, + startedAt: null, + }); }); it("shows empty state when no items", () => { @@ -84,8 +93,9 @@ describe("QueueView", () => { expect(screen.getByText("Queued item")).toBeInTheDocument(); }); - it("calls onSetFocus when an item is clicked", async () => { + it("calls onSetFocus and switches to focus when an item is clicked", async () => { const onSetFocus = vi.fn(); + const onSwitchToFocus = vi.fn(); const user = userEvent.setup(); useFocusStore.setState({ @@ -95,10 +105,13 @@ describe("QueueView", () => { ], }); - render(); + render( + + ); await user.click(screen.getByText("Click me")); expect(onSetFocus).toHaveBeenCalledWith("target"); + expect(onSwitchToFocus).toHaveBeenCalled(); }); it("marks the current focus item visually", () => { @@ -113,4 +126,325 @@ describe("QueueView", () => { expect(container.querySelector("[data-focused='true']")).toBeTruthy(); }); + + // -- Snoozed items filtering -- + + describe("snoozed items", () => { + it("hides snoozed items by default", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + useFocusStore.setState({ + current: makeFocusItem({ id: "active", title: "Active item" }), + queue: [ + makeFocusItem({ + id: "snoozed", + type: "issue", + title: "Snoozed item", + snoozedUntil: future, + }), + makeFocusItem({ + id: "visible", + type: "issue", + title: "Visible item", + snoozedUntil: null, + }), + ], + }); + + render(); + + expect(screen.queryByText("Snoozed item")).not.toBeInTheDocument(); + expect(screen.getByText("Visible item")).toBeInTheDocument(); + }); + + it("shows snoozed items when showSnoozed is true", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + useFocusStore.setState({ + current: makeFocusItem({ id: "active", title: "Active item" }), + queue: [ + makeFocusItem({ + id: "snoozed", + type: "issue", + title: "Snoozed item", + snoozedUntil: future, + }), + ], + }); + + render( + + ); + + expect(screen.getByText("Snoozed item")).toBeInTheDocument(); + }); + + it("shows items with expired snooze time", () => { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ + id: "expired", + type: "issue", + title: "Expired snooze", + snoozedUntil: past, + }), + ], + }); + + render(); + + expect(screen.getByText("Expired snooze")).toBeInTheDocument(); + }); + + it("shows snooze count indicator when items are hidden", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + useFocusStore.setState({ + current: makeFocusItem({ id: "active", title: "Active item" }), + queue: [ + makeFocusItem({ + id: "snoozed1", + type: "issue", + title: "Snoozed 1", + snoozedUntil: future, + }), + makeFocusItem({ + id: "snoozed2", + type: "mr_review", + title: "Snoozed 2", + snoozedUntil: future, + }), + ], + }); + + render(); + + expect(screen.getByText(/2 snoozed/i)).toBeInTheDocument(); + }); + }); + + // -- Filtering via type -- + + describe("filtering", () => { + it("filters items by type when filter is applied", () => { + useFocusStore.setState({ + current: makeFocusItem({ id: "current", type: "mr_review" }), + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }), + makeFocusItem({ id: "m1", type: "manual", title: "Task 1" }), + ], + }); + + render( + + ); + + expect(screen.queryByText("Review 1")).not.toBeInTheDocument(); + expect(screen.getByText("Issue 1")).toBeInTheDocument(); + expect(screen.queryByText("Task 1")).not.toBeInTheDocument(); + }); + + it("shows all types when no filter is applied", () => { + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }), + ], + }); + + render(); + + expect(screen.getByText("Review 1")).toBeInTheDocument(); + expect(screen.getByText("Issue 1")).toBeInTheDocument(); + }); + }); + + // -- Batch mode entry -- + + describe("batch mode", () => { + it("shows batch button for sections with multiple items", () => { + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "r2", type: "mr_review", title: "Review 2" }), + makeFocusItem({ id: "r3", type: "mr_review", title: "Review 3" }), + ], + }); + + render( + + ); + + const batchButton = screen.getByRole("button", { name: /batch/i }); + expect(batchButton).toBeInTheDocument(); + }); + + it("does not show batch button for sections with single item", () => { + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }), + ], + }); + + render(); + + // Neither section has multiple items + expect( + screen.queryByRole("button", { name: /batch/i }) + ).not.toBeInTheDocument(); + }); + + it("calls onStartBatch with section items when batch button clicked", async () => { + const onStartBatch = vi.fn(); + const user = userEvent.setup(); + + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "r2", type: "mr_review", title: "Review 2" }), + ], + }); + + render( + + ); + + const batchButton = screen.getByRole("button", { name: /batch/i }); + await user.click(batchButton); + + expect(onStartBatch).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "r1" }), + expect.objectContaining({ id: "r2" }), + ]), + "REVIEWS" + ); + }); + }); + + // -- Command palette integration -- + + describe("command palette", () => { + it("opens command palette on Cmd+K", async () => { + const user = userEvent.setup(); + + useFocusStore.setState({ + current: makeFocusItem({ id: "current" }), + queue: [], + }); + + render(); + + await user.keyboard("{Meta>}k{/Meta}"); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("filters items when command is selected", async () => { + const user = userEvent.setup(); + + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }), + makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }), + ], + }); + + render(); + + // Open palette + await user.keyboard("{Meta>}k{/Meta}"); + + // Type filter command prefix to enter command mode + const input = screen.getByRole("textbox"); + await user.type(input, "type:"); + + // Click the type:issue option directly + const issueOption = screen.getByRole("option", { name: /type:issue/i }); + await user.click(issueOption); + + // Should only show issues + expect(screen.queryByText("Review 1")).not.toBeInTheDocument(); + expect(screen.getByText("Issue 1")).toBeInTheDocument(); + }); + }); + + // -- Header actions -- + + describe("header", () => { + it("shows Back to Focus button", () => { + useFocusStore.setState({ + current: makeFocusItem({ id: "current" }), + queue: [], + }); + + render(); + + expect( + screen.getByRole("button", { name: /back to focus/i }) + ).toBeInTheDocument(); + }); + + it("calls onSwitchToFocus when Back to Focus clicked", async () => { + const onSwitchToFocus = vi.fn(); + const user = userEvent.setup(); + + useFocusStore.setState({ + current: makeFocusItem({ id: "current" }), + queue: [], + }); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /back to focus/i })); + expect(onSwitchToFocus).toHaveBeenCalled(); + }); + + it("shows filter indicator when filter is active", () => { + useFocusStore.setState({ + current: null, + queue: [ + makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }), + ], + }); + + render( + + ); + + expect(screen.getByText(/filtered/i)).toBeInTheDocument(); + }); + }); }); diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts index c7518bb..609ed0b 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -4,7 +4,7 @@ * Centralized here to avoid duplication across test files. */ -import type { FocusItem } from "@/lib/types"; +import type { FocusItem, InboxItem } from "@/lib/types"; /** Create a FocusItem with sensible defaults, overridable per field. */ export function makeFocusItem( @@ -20,6 +20,23 @@ export function makeFocusItem( updatedAt: new Date().toISOString(), contextQuote: null, requestedBy: null, + snoozedUntil: null, + ...overrides, + }; +} + +/** Create an InboxItem with sensible defaults, overridable per field. */ +export function makeInboxItem( + overrides: Partial = {} +): InboxItem { + return { + id: "inbox-item-1", + title: "You were mentioned in #312", + type: "mention", + triaged: false, + createdAt: new Date().toISOString(), + snippet: "@user can you look at this?", + actor: "alice", ...overrides, }; }