diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5dce930..e5b690c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+
jobs:
backend:
name: Backend (lint + tests + compose)
diff --git a/frontend/src/app/RouteFallback.tsx b/frontend/src/app/RouteFallback.tsx
new file mode 100644
index 0000000..8d1142a
--- /dev/null
+++ b/frontend/src/app/RouteFallback.tsx
@@ -0,0 +1,16 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export function RouteFallback() {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index e3f0158..c25e711 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -2,7 +2,7 @@ import { lazy, Suspense } from "react";
import { createBrowserRouter, Navigate } from "react-router-dom";
import { AppShell } from "@/layouts/AppShell";
-import { Skeleton } from "@/components/ui/skeleton";
+import { RouteFallback } from "@/app/RouteFallback";
// Each page is split into its own chunk so the initial bundle only ships the
// app shell + the page the user actually opens. Named exports are remapped to
@@ -35,21 +35,6 @@ const SettingsPage = lazy(() =>
import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage }))
);
-function RouteFallback() {
- return (
-
-
-
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
-
-
- );
-}
-
function withSuspense(node: React.ReactNode) {
return }>{node};
}
diff --git a/frontend/src/components/common/StatusChip.tsx b/frontend/src/components/common/StatusChip.tsx
index 4aa35aa..d5a2f6b 100644
--- a/frontend/src/components/common/StatusChip.tsx
+++ b/frontend/src/components/common/StatusChip.tsx
@@ -1,14 +1,5 @@
import { cn } from "@/lib/utils";
-
-const TONE: Record = {
- ok: { dot: "bg-success", text: "text-success", bg: "bg-success/10" },
- active: { dot: "bg-primary", text: "text-primary-700 dark:text-primary-100", bg: "bg-primary/10" },
- warning: { dot: "bg-warning", text: "text-warning", bg: "bg-warning/10" },
- error: { dot: "bg-destructive", text: "text-destructive", bg: "bg-destructive/10" },
- muted: { dot: "bg-muted-foreground", text: "text-muted-foreground", bg: "bg-muted/60" },
-};
-
-export type StatusTone = keyof typeof TONE;
+import { STATUS_TONE, type StatusTone } from "@/components/common/statusTone";
export function StatusChip({
tone = "muted",
@@ -19,7 +10,7 @@ export function StatusChip({
label: string;
className?: string;
}) {
- const t = TONE[tone];
+ const t = STATUS_TONE[tone];
return (
);
}
-
-export function statusToTone(status: string): StatusTone {
- const s = status?.toUpperCase();
- if (!s) return "muted";
- if (s.includes("FAILED") || s === "ERROR") return "error";
- if (s === "INDEXING_COMPLETED" || s === "OK") return "ok";
- if (s === "DISCOVERED" || s.endsWith("_STARTED") || s === "PENDING") return "active";
- if (s === "OCR_COMPLETED" || s === "EXTRACTION_COMPLETED" || s === "CHUNKING_COMPLETED")
- return "active";
- if (s === "DEGRADED") return "warning";
- return "muted";
-}
diff --git a/frontend/src/components/common/statusTone.ts b/frontend/src/components/common/statusTone.ts
new file mode 100644
index 0000000..49b0810
--- /dev/null
+++ b/frontend/src/components/common/statusTone.ts
@@ -0,0 +1,21 @@
+export const STATUS_TONE = {
+ ok: { dot: "bg-success", text: "text-success", bg: "bg-success/10" },
+ active: { dot: "bg-primary", text: "text-primary-700 dark:text-primary-100", bg: "bg-primary/10" },
+ warning: { dot: "bg-warning", text: "text-warning", bg: "bg-warning/10" },
+ error: { dot: "bg-destructive", text: "text-destructive", bg: "bg-destructive/10" },
+ muted: { dot: "bg-muted-foreground", text: "text-muted-foreground", bg: "bg-muted/60" },
+} as const;
+
+export type StatusTone = keyof typeof STATUS_TONE;
+
+export function statusToTone(status: string): StatusTone {
+ const s = status?.toUpperCase();
+ if (!s) return "muted";
+ if (s.includes("FAILED") || s === "ERROR") return "error";
+ if (s === "INDEXING_COMPLETED" || s === "OK") return "ok";
+ if (s === "DISCOVERED" || s.endsWith("_STARTED") || s === "PENDING") return "active";
+ if (s === "OCR_COMPLETED" || s === "EXTRACTION_COMPLETED" || s === "CHUNKING_COMPLETED")
+ return "active";
+ if (s === "DEGRADED") return "warning";
+ return "muted";
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
index a06125a..2016909 100644
--- a/frontend/src/components/ui/button.tsx
+++ b/frontend/src/components/ui/button.tsx
@@ -1,45 +1,11 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-focus transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "bg-primary text-primary-foreground shadow-soft hover:bg-primary-700 active:translate-y-[0.5px]",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border/70",
- outline:
- "border border-border bg-transparent hover:bg-muted text-foreground",
- ghost:
- "hover:bg-muted text-foreground",
- subtle:
- "bg-accent text-accent-foreground hover:bg-accent/70",
- destructive:
- "bg-destructive text-destructive-foreground hover:bg-destructive/90",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- sm: "h-8 px-3 text-xs",
- default: "h-9 px-4",
- lg: "h-11 px-6 text-base rounded-xl",
- icon: "h-9 w-9",
- "icon-sm": "h-8 w-8",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-);
+import { buttonVariants, type ButtonVariantProps } from "@/components/ui/buttonVariants";
export interface ButtonProps
extends React.ButtonHTMLAttributes,
- VariantProps {
+ ButtonVariantProps {
asChild?: boolean;
}
@@ -52,5 +18,3 @@ export const Button = React.forwardRef(
}
);
Button.displayName = "Button";
-
-export { buttonVariants };
diff --git a/frontend/src/components/ui/buttonVariants.ts b/frontend/src/components/ui/buttonVariants.ts
new file mode 100644
index 0000000..b40a38b
--- /dev/null
+++ b/frontend/src/components/ui/buttonVariants.ts
@@ -0,0 +1,37 @@
+import { cva, type VariantProps } from "class-variance-authority";
+
+export const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-focus transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-soft hover:bg-primary-700 active:translate-y-[0.5px]",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border/70",
+ outline:
+ "border border-border bg-transparent hover:bg-muted text-foreground",
+ ghost:
+ "hover:bg-muted text-foreground",
+ subtle:
+ "bg-accent text-accent-foreground hover:bg-accent/70",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ sm: "h-8 px-3 text-xs",
+ default: "h-9 px-4",
+ lg: "h-11 px-6 text-base rounded-xl",
+ icon: "h-9 w-9",
+ "icon-sm": "h-8 w-8",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export type ButtonVariantProps = VariantProps;
diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx
index 54e3aa1..0a42abb 100644
--- a/frontend/src/pages/DocumentsPage.tsx
+++ b/frontend/src/pages/DocumentsPage.tsx
@@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ConfidenceMeter } from "@/components/common/ConfidenceMeter";
-import { StatusChip, statusToTone } from "@/components/common/StatusChip";
+import { StatusChip } from "@/components/common/StatusChip";
+import { statusToTone } from "@/components/common/statusTone";
import { QualityFlags } from "@/components/common/QualityFlag";
import { EmptyState } from "@/components/common/EmptyState";
import { useDocuments } from "@/hooks/useDocuments";
@@ -31,7 +32,7 @@ export function DocumentsPage() {
page_size: 200,
});
- const items = data?.items ?? [];
+ const items = useMemo(() => data?.items ?? [], [data?.items]);
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
diff --git a/frontend/src/pages/IngestionJobsPage.tsx b/frontend/src/pages/IngestionJobsPage.tsx
index 93d5f28..9f18629 100644
--- a/frontend/src/pages/IngestionJobsPage.tsx
+++ b/frontend/src/pages/IngestionJobsPage.tsx
@@ -7,7 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
-import { StatusChip, statusToTone } from "@/components/common/StatusChip";
+import { StatusChip } from "@/components/common/StatusChip";
+import { statusToTone } from "@/components/common/statusTone";
import { Progress } from "@/components/ui/progress";
import { formatNumber, relativeTime } from "@/lib/utils";
import { useIngestionRuns, useStartIngestion } from "@/hooks/useIngestion";
diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx
index 328cbe9..c9f6733 100644
--- a/frontend/src/pages/SearchPage.tsx
+++ b/frontend/src/pages/SearchPage.tsx
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
-import { AnimatePresence, motion } from "framer-motion";
+import { AnimatePresence } from "framer-motion";
import { ArrowRight, Filter, Loader2, Search as SearchIcon, Sparkles, X } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -26,7 +26,7 @@ import type { SearchMode } from "@/services/types";
import { cn, formatNumber } from "@/lib/utils";
export function SearchPage() {
- const { query, mode, filters, setQuery, setMode, setFilters, pushHistory, history } = useSearchStore();
+ const { query, mode, filters, setQuery, setMode, pushHistory, history } = useSearchStore();
const [draft, setDraft] = useState(query);
const [activeId, setActiveId] = useState(null);
const debounced = useDebounce(draft, 320);
@@ -44,7 +44,7 @@ export function SearchPage() {
enabled: query.trim().length > 0,
});
- const results = data?.results ?? [];
+ const results = useMemo(() => data?.results ?? [], [data?.results]);
const active = useMemo(
() => results.find((r) => r.chunk_id === activeId) ?? results[0] ?? null,
[results, activeId]
diff --git a/frontend/src/widgets/RecentRunsWidget.tsx b/frontend/src/widgets/RecentRunsWidget.tsx
index eedf699..cf469b1 100644
--- a/frontend/src/widgets/RecentRunsWidget.tsx
+++ b/frontend/src/widgets/RecentRunsWidget.tsx
@@ -1,5 +1,6 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { StatusChip, statusToTone } from "@/components/common/StatusChip";
+import { StatusChip } from "@/components/common/StatusChip";
+import { statusToTone } from "@/components/common/statusTone";
import { useIngestionRuns } from "@/hooks/useIngestion";
import { formatNumber, relativeTime } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";