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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 11:00:15 -05:00
parent d7056cc86f
commit d4b8a4baea
4 changed files with 541 additions and 98 deletions

View File

@@ -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<FilterCriteria>({});
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 (
<div className="flex min-h-screen flex-col items-center justify-center">
<p className="text-zinc-500">No items in the queue</p>
@@ -54,13 +137,26 @@ export function QueueView({
);
}
const sections = groupByType(allItems);
const sections = groupByType(filteredItems);
const isFiltered = effectiveFilterType !== undefined;
return (
<div className="flex min-h-screen flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4">
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
{isFiltered && (
<span className="rounded bg-zinc-700 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
Filtered
</span>
)}
{!showSnoozed && snoozedCount > 0 && (
<span className="text-xs text-zinc-500">
{snoozedCount} snoozed
</span>
)}
</div>
<button
type="button"
onClick={onSwitchToFocus}
@@ -72,33 +168,61 @@ export function QueueView({
{/* Sections */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{sections.map((section, sectionIdx) => (
<motion.div
key={section.type}
className="mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
>
<h2 className="mb-2 text-xs font-bold tracking-wider text-zinc-500">
{section.label} ({section.items.length})
</h2>
<div className="flex flex-col gap-1.5">
{section.items.map((item) => (
<QueueItem
key={item.id}
item={item}
onClick={(id) => {
onSetFocus(id);
onSwitchToFocus();
}}
isFocused={current?.id === item.id}
/>
))}
</div>
</motion.div>
))}
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-zinc-500">No items match the filter</p>
</div>
) : (
sections.map((section, sectionIdx) => (
<motion.div
key={section.type}
className="mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-bold tracking-wider text-zinc-500">
{section.label} ({section.items.length})
</h2>
{section.items.length >= 2 && onStartBatch && (
<button
type="button"
onClick={() =>
handleStartBatch(section.items, section.label)
}
className="rounded border border-zinc-700 px-2 py-0.5 text-[10px] font-medium text-zinc-400 transition-colors hover:border-zinc-600 hover:text-zinc-300"
>
Batch
</button>
)}
</div>
<div className="flex flex-col gap-1.5">
{section.items.map((item) => (
<QueueItem
key={item.id}
item={item}
onClick={(id) => {
onSetFocus(id);
onSwitchToFocus();
}}
isFocused={current?.id === item.id}
/>
))}
</div>
</motion.div>
))
)}
</div>
{/* Command Palette */}
<CommandPalette
isOpen={isPaletteOpen}
items={visibleItems}
onFilter={handleFilter}
onSelect={handlePaletteSelect}
onClose={handleClosePalette}
/>
</div>
);
}

View File

@@ -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 */

View File

@@ -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(<QueueView onSetFocus={onSetFocus} onSwitchToFocus={vi.fn()} />);
render(
<QueueView onSetFocus={onSetFocus} onSwitchToFocus={onSwitchToFocus} />
);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(
<QueueView
onSetFocus={vi.fn()}
onSwitchToFocus={vi.fn()}
showSnoozed={true}
/>
);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(
<QueueView
onSetFocus={vi.fn()}
onSwitchToFocus={vi.fn()}
filterType="issue"
/>
);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(
<QueueView
onSetFocus={vi.fn()}
onSwitchToFocus={vi.fn()}
onStartBatch={vi.fn()}
/>
);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
// 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(
<QueueView
onSetFocus={vi.fn()}
onSwitchToFocus={vi.fn()}
onStartBatch={onStartBatch}
/>
);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
// 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(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
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(
<QueueView onSetFocus={vi.fn()} onSwitchToFocus={onSwitchToFocus} />
);
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(
<QueueView
onSetFocus={vi.fn()}
onSwitchToFocus={vi.fn()}
filterType="issue"
/>
);
expect(screen.getByText(/filtered/i)).toBeInTheDocument();
});
});
});

View File

@@ -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> = {}
): 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,
};
}