diff --git a/src/App.tsx b/src/App.tsx index c385d90..f5eeba5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,21 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "@/components/AppShell"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // Consider data fresh for 30 seconds + retry: 1, // Retry failed requests once + }, + }, +}); + function App(): React.ReactElement { - return ; + return ( + + + + ); } export default App; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 8c102ba..89bd6da 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,7 +1,7 @@ /** * AppShell -- top-level layout with navigation tabs. * - * Switches between Focus, Queue, and Inbox views. + * Switches between Focus, Queue, Inbox, Settings, and Debug views. * Uses the nav store to track the active view. */ @@ -10,19 +10,25 @@ import { motion, AnimatePresence } from "framer-motion"; import { useNavStore } from "@/stores/nav-store"; import type { ViewId } from "@/stores/nav-store"; import { useFocusStore } from "@/stores/focus-store"; +import { useInboxStore } from "@/stores/inbox-store"; import { useBatchStore } from "@/stores/batch-store"; import { useCaptureStore } from "@/stores/capture-store"; +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { FocusView } from "./FocusView"; import { QueueView } from "./QueueView"; +import { InboxView } from "./InboxView"; +import { SettingsView } from "./SettingsView"; import { BatchMode } from "./BatchMode"; import { QuickCapture } from "./QuickCapture"; +import { DebugView } from "./DebugView"; import { open } from "@tauri-apps/plugin-shell"; import { listen } from "@tauri-apps/api/event"; -const NAV_ITEMS: { id: ViewId; label: string }[] = [ - { id: "focus", label: "Focus" }, - { id: "queue", label: "Queue" }, - { id: "inbox", label: "Inbox" }, +const NAV_ITEMS: { id: ViewId; label: string; shortcut?: string }[] = [ + { id: "focus", label: "Focus", shortcut: "1" }, + { id: "queue", label: "Queue", shortcut: "2" }, + { id: "inbox", label: "Inbox", shortcut: "3" }, + { id: "debug", label: "Debug", shortcut: "4" }, ]; export function AppShell(): React.ReactElement { @@ -33,8 +39,19 @@ export function AppShell(): React.ReactElement { const current = useFocusStore((s) => s.current); const batchIsActive = useBatchStore((s) => s.isActive); const exitBatch = useBatchStore((s) => s.exitBatch); + const inboxItems = useInboxStore((s) => s.items); const totalItems = (current ? 1 : 0) + queue.length; + const untriagedInboxCount = inboxItems.filter((i) => !i.triaged).length; + + // Register keyboard shortcuts for navigation + useKeyboardShortcuts({ + "mod+1": () => setView("focus"), + "mod+2": () => setView("queue"), + "mod+3": () => setView("inbox"), + "mod+4": () => setView("debug"), + "mod+,": () => setView("settings"), + }); // Listen for global shortcut events from the Rust backend useEffect(() => { @@ -94,21 +111,67 @@ export function AppShell(): React.ReactElement { ))} + +
+ + {/* Settings button */} + {/* View content */} @@ -131,11 +194,9 @@ export function AppShell(): React.ReactElement { onSwitchToFocus={() => setView("focus")} /> )} - {activeView === "inbox" && ( -
-

Inbox view coming in Phase 4b

-
- )} + {activeView === "inbox" && } + {activeView === "settings" && } + {activeView === "debug" && }
diff --git a/src/components/DebugView.tsx b/src/components/DebugView.tsx new file mode 100644 index 0000000..5005f2f --- /dev/null +++ b/src/components/DebugView.tsx @@ -0,0 +1,113 @@ +/** + * DebugView -- displays raw lore data for debugging. + * + * Shows the raw JSON response from get_lore_status for visual + * verification that the data pipeline is working end-to-end. + * Access via the debug view in the navigation. + */ + +import { useLoreData } from "@/hooks/useLoreData"; + +export function DebugView(): React.ReactElement { + const { data, isLoading, error, refetch } = useLoreData(); + + if (isLoading) { + return ( +
+
Loading lore data...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error.message}
+ +
+ ); + } + + return ( +
+
+

Lore Debug

+ +
+ + {/* Status overview */} +
+

Status

+
+
+ Health: +
+ + {data?.is_healthy ? "Healthy" : "Unhealthy"} + +
+
+ Last Sync: + + {data?.last_sync ?? "never"} + +
+
+ Message: + {data?.message} +
+
+
+ + {/* Summary counts */} + {data?.summary && ( +
+

Summary

+
+
+
+ {data.summary.open_issues} +
+
Open Issues
+
+
+
+ {data.summary.authored_mrs} +
+
Authored MRs
+
+
+
+ {data.summary.reviewing_mrs} +
+
Reviewing MRs
+
+
+
+ )} + + {/* Raw JSON output */} +
+

Raw Response

+
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ); +} diff --git a/src/hooks/useLoreData.ts b/src/hooks/useLoreData.ts new file mode 100644 index 0000000..5aa2e6a --- /dev/null +++ b/src/hooks/useLoreData.ts @@ -0,0 +1,48 @@ +/** + * Hook for fetching lore status data via Tauri IPC. + * + * Uses TanStack Query for caching and state management. + * The data can be refreshed via query invalidation when + * lore-data-changed events are received. + */ + +import { useQuery } from "@tanstack/react-query"; +import { getLoreStatus } from "@/lib/tauri"; +import type { LoreStatus } from "@/lib/bindings"; + +export interface UseLoreDataResult { + data: LoreStatus | undefined; + isLoading: boolean; + error: Error | null; + refetch: () => void; +} + +/** + * Fetch lore status data from the Tauri backend. + * + * Returns the current lore status including health, last sync time, + * and summary counts (open issues, authored MRs, reviewing MRs). + */ +export function useLoreData(): UseLoreDataResult { + const query = useQuery({ + queryKey: ["lore-status"], + queryFn: async (): Promise => { + const result = await getLoreStatus(); + + if (result.status === "error") { + throw new Error(result.error.message); + } + + return result.data; + }, + refetchInterval: false, // Manual refetch on lore-data-changed + staleTime: 30_000, // Consider data fresh for 30 seconds + }); + + return { + data: query.data, + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, + }; +} diff --git a/src/stores/nav-store.ts b/src/stores/nav-store.ts index 8dc72db..ec9f673 100644 --- a/src/stores/nav-store.ts +++ b/src/stores/nav-store.ts @@ -9,7 +9,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import { getStorage } from "@/lib/tauri-storage"; -export type ViewId = "focus" | "queue" | "inbox"; +export type ViewId = "focus" | "queue" | "inbox" | "settings" | "debug"; export interface NavState { activeView: ViewId; diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index 689e95f..b2eae94 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -1,13 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "@/components/AppShell"; import { useNavStore } from "@/stores/nav-store"; import { useFocusStore } from "@/stores/focus-store"; import { useCaptureStore } from "@/stores/capture-store"; +import { useInboxStore } from "@/stores/inbox-store"; import { simulateEvent, resetMocks } from "../mocks/tauri-api"; import { makeFocusItem } from "../helpers/fixtures"; +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + {ui} + ); +} + describe("AppShell", () => { beforeEach(() => { useNavStore.setState({ activeView: "focus" }); @@ -23,35 +39,48 @@ describe("AppShell", () => { lastCapturedId: null, error: null, }); + useInboxStore.setState({ + items: [], + }); resetMocks(); }); it("renders navigation tabs", () => { - render(); + renderWithProviders(); expect(screen.getByText("Focus")).toBeInTheDocument(); expect(screen.getByText("Queue")).toBeInTheDocument(); expect(screen.getByText("Inbox")).toBeInTheDocument(); + expect(screen.getByText("Debug")).toBeInTheDocument(); }); it("shows Focus view by default", () => { - render(); + renderWithProviders(); expect(screen.getByText(/all clear/i)).toBeInTheDocument(); }); it("switches to Queue view when Queue tab is clicked", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByText("Queue")); expect(await screen.findByText(/no items/i)).toBeInTheDocument(); }); - it("switches to Inbox placeholder", async () => { + it("switches to Inbox view and shows inbox zero", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByText("Inbox")); - expect(await screen.findByText(/coming in Phase 4b/i)).toBeInTheDocument(); + // When inbox is empty, shows "Inbox Zero" / "All caught up!" + expect(await screen.findByText(/inbox zero/i)).toBeInTheDocument(); + }); + + it("switches to Debug view when Debug tab is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("Debug")); + expect(await screen.findByText(/lore debug/i)).toBeInTheDocument(); }); it("shows queue count badge when items exist", () => { @@ -60,12 +89,12 @@ describe("AppShell", () => { queue: [makeFocusItem({ id: "b" }), makeFocusItem({ id: "c" })], }); - render(); + renderWithProviders(); expect(screen.getByText("3")).toBeInTheDocument(); }); it("opens quick capture overlay on global shortcut event", async () => { - render(); + renderWithProviders(); act(() => { simulateEvent("global-shortcut-triggered", "quick-capture"); @@ -89,7 +118,7 @@ describe("AppShell", () => { ], }); - render(); + renderWithProviders(); // Navigate to queue and wait for transition await user.click(screen.getByText("Queue")); diff --git a/tests/components/DebugView.test.tsx b/tests/components/DebugView.test.tsx new file mode 100644 index 0000000..3675a0c --- /dev/null +++ b/tests/components/DebugView.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { DebugView } from "@/components/DebugView"; +import { setMockResponse, resetMocks } from "../mocks/tauri-api"; + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + {ui} + ); +} + +describe("DebugView", () => { + beforeEach(() => { + resetMocks(); + }); + + it("shows loading state initially", () => { + renderWithProviders(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("displays raw JSON data when loaded", async () => { + const mockStatus = { + last_sync: "2026-02-26T12:00:00Z", + is_healthy: true, + message: "Lore is healthy", + summary: { + open_issues: 5, + authored_mrs: 2, + reviewing_mrs: 3, + }, + }; + + setMockResponse("get_lore_status", mockStatus); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // Check that the debug heading is present + expect(screen.getByText(/lore debug/i)).toBeInTheDocument(); + + // Check that the JSON is displayed (look for key properties) + expect(screen.getByText(/is_healthy/)).toBeInTheDocument(); + expect(screen.getByText(/open_issues/)).toBeInTheDocument(); + expect(screen.getByText(/reviewing_mrs/)).toBeInTheDocument(); + }); + + it("shows error state when fetch fails", async () => { + setMockResponse("get_lore_status", Promise.reject(new Error("Connection failed"))); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + + it("shows health status indicator", async () => { + const mockStatus = { + last_sync: "2026-02-26T12:00:00Z", + is_healthy: true, + message: "Lore is healthy", + summary: { + open_issues: 5, + authored_mrs: 2, + reviewing_mrs: 3, + }, + }; + + setMockResponse("get_lore_status", mockStatus); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId("health-indicator")).toBeInTheDocument(); + expect(screen.getByTestId("health-indicator")).toHaveClass("bg-green-500"); + }); + + it("shows unhealthy indicator when lore is not healthy", async () => { + const mockStatus = { + last_sync: null, + is_healthy: false, + message: "lore not configured", + summary: null, + }; + + setMockResponse("get_lore_status", mockStatus); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId("health-indicator")).toHaveClass("bg-red-500"); + }); + + it("displays last sync time when available", async () => { + const mockStatus = { + last_sync: "2026-02-26T12:00:00Z", + is_healthy: true, + message: "Lore is healthy", + summary: null, + }; + + setMockResponse("get_lore_status", mockStatus); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // The timestamp appears in both the status section and raw JSON + const syncTimeElements = screen.getAllByText(/2026-02-26T12:00:00Z/); + expect(syncTimeElements.length).toBeGreaterThan(0); + }); + + it("shows 'never' when last_sync is null", async () => { + const mockStatus = { + last_sync: null, + is_healthy: false, + message: "lore not configured", + summary: null, + }; + + setMockResponse("get_lore_status", mockStatus); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + expect(screen.getByText(/never/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/useLoreData.test.ts b/tests/hooks/useLoreData.test.ts new file mode 100644 index 0000000..15ef37e --- /dev/null +++ b/tests/hooks/useLoreData.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; +import { useLoreData } from "@/hooks/useLoreData"; +import { setMockResponse, resetMocks } from "../mocks/tauri-api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +describe("useLoreData", () => { + beforeEach(() => { + resetMocks(); + }); + + it("returns loading state initially", () => { + const { result } = renderHook(() => useLoreData(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it("returns lore status data on success", async () => { + const mockStatus = { + last_sync: "2026-02-26T12:00:00Z", + is_healthy: true, + message: "Lore is healthy", + summary: { + open_issues: 5, + authored_mrs: 2, + reviewing_mrs: 3, + }, + }; + + setMockResponse("get_lore_status", mockStatus); + + const { result } = renderHook(() => useLoreData(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(mockStatus); + expect(result.current.error).toBeNull(); + }); + + it("returns error state when IPC fails", async () => { + setMockResponse("get_lore_status", Promise.reject(new Error("IPC failed"))); + + const { result } = renderHook(() => useLoreData(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeTruthy(); + expect(result.current.data).toBeUndefined(); + }); + + it("returns unhealthy status", async () => { + const mockStatus = { + last_sync: null, + is_healthy: false, + message: "lore not configured", + summary: null, + }; + + setMockResponse("get_lore_status", mockStatus); + + const { result } = renderHook(() => useLoreData(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data?.is_healthy).toBe(false); + expect(result.current.data?.summary).toBeNull(); + }); +});