Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
+36
@@ -0,0 +1,36 @@
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AppRoutes } from "./app/routes";
|
||||
import { RuntimeErrorBoundary } from "@/shared/components/RuntimeErrorBoundary";
|
||||
import { AuthProvider, useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { Login } from "@/features/auth/pages/Login";
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <main className="flex min-h-screen items-center justify-center" aria-busy="true">ProjectBrain wordt veilig geladen…</main>;
|
||||
if (!user) return <Login />;
|
||||
return <AppRoutes />;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<RuntimeErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</RuntimeErrorBoundary>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React from "react";
|
||||
import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom";
|
||||
import { ProjectProvider, useProject } from "@/shared/contexts/ProjectContext";
|
||||
import AppLayout from "@/components/layout/AppLayout";
|
||||
import Projects from "@/features/projects/pages/Projects";
|
||||
import GuidedStart from "@/features/start/pages/GuidedStart";
|
||||
import NotFound from "@/shared/pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { resolveProjectResume } from "@/shared/lib/projectResume";
|
||||
import { projectRoutePath, projectRoutePattern } from "@/shared/lib/routeRegistry";
|
||||
import { resetApplicationScroll } from "./scrollReset";
|
||||
|
||||
const ProjectSetupFlow = React.lazy(() => import("@/features/projects/pages/FirstRunProjectWizard"));
|
||||
const ProjectOverview = React.lazy(() => import("@/features/projects/pages/ProjectOverview"));
|
||||
const Workspace = React.lazy(() => import("@/features/projects/pages/Workspace"));
|
||||
const ProjectResults = React.lazy(() => import("@/features/projects/pages/ProjectResults"));
|
||||
const ProjectDNA = React.lazy(() => import("@/features/projects/pages/ProjectDNA"));
|
||||
const RuntimeReadiness = React.lazy(() => import("@/features/projects/pages/RuntimeReadiness"));
|
||||
const RepositoryAnalysisJobs = React.lazy(() => import("@/features/operations/pages/RepositoryAnalysisJobs"));
|
||||
const FindingInbox = React.lazy(() => import("@/features/operations/pages/FindingInbox"));
|
||||
const Insights = React.lazy(() => import("@/features/insights/pages/Insights"));
|
||||
const ContextPacks = React.lazy(() => import("@/features/context-packs/pages/ContextPacks"));
|
||||
const FeatureMap = React.lazy(() => import("@/features/features/pages/FeatureMap"));
|
||||
const FeatureDetail = React.lazy(() => import("@/features/features/pages/FeatureDetail"));
|
||||
const AIReview = React.lazy(() => import("@/features/checks/pages/AIReview"));
|
||||
const MemoryRules = React.lazy(() => import("@/features/memory/pages/MemoryRules"));
|
||||
const PromptGenerator = React.lazy(() => import("@/features/prompts/pages/PromptGenerator"));
|
||||
const BuildJournal = React.lazy(() => import("@/features/builds/pages/BuildJournal"));
|
||||
const RegressionMemory = React.lazy(() => import("@/features/regression/pages/RegressionMemory"));
|
||||
const Tasks = React.lazy(() => import("@/features/tasks/pages/Tasks"));
|
||||
const Bugs = React.lazy(() => import("@/features/bugs/pages/Bugs"));
|
||||
const Decisions = React.lazy(() => import("@/features/decisions/pages/Decisions"));
|
||||
const Relationships = React.lazy(() => import("@/features/relationships/pages/RelationshipGraph"));
|
||||
const KnowledgeVault = React.lazy(() => import("@/features/knowledge/pages/KnowledgeVault"));
|
||||
const ArchitectureMap = React.lazy(() => import("@/features/architecture/pages/ArchitectureMap"));
|
||||
const Snapshots = React.lazy(() => import("@/features/snapshots/pages/Snapshots"));
|
||||
const SnapshotDetail = React.lazy(() => import("@/features/snapshots/pages/SnapshotDetail"));
|
||||
const CaptureCenter = React.lazy(() => import("@/features/operations/pages/CaptureCenter"));
|
||||
const ReviewQueue = React.lazy(() => import("@/features/operations/pages/ReviewQueue"));
|
||||
const AIActionCenter = React.lazy(() => import("@/features/operations/pages/AIActionCenter"));
|
||||
const Initiatives = React.lazy(() => import("@/features/operations/pages/Initiatives"));
|
||||
const InitiativeDetail = React.lazy(() => import("@/features/operations/pages/InitiativeDetail"));
|
||||
const DocumentationHub = React.lazy(() => import("@/features/operations/pages/DocumentationHub"));
|
||||
const ExtraSources = React.lazy(() => import("@/features/source-library/pages/ExtraSources"));
|
||||
const SourceLibrary = React.lazy(() => import("@/features/source-library/pages/SourceLibrary"));
|
||||
const ProjectActivity = React.lazy(() => import("@/features/history/pages/ProjectActivity"));
|
||||
const ProjectHistory = React.lazy(() => import("@/features/history/pages/ProjectHistory"));
|
||||
const ReleaseCenter = React.lazy(() => import("@/features/release/pages/ReleaseCenter"));
|
||||
const ExecutionCenter = React.lazy(() => import("@/features/execution/pages/ExecutionCenter"));
|
||||
const ExecutionFeedback = React.lazy(() => import("@/features/execution-feedback/pages/ExecutionFeedback"));
|
||||
const BuildLoopDashboard = React.lazy(() => import("@/features/build-loop/pages/BuildLoopDashboard"));
|
||||
const AutoMode = React.lazy(() => import("@/features/autonomy/pages/AutoMode"));
|
||||
const SessionPacks = React.lazy(() => import("@/features/session-packs/pages/SessionPacks"));
|
||||
const BrowserBridge = React.lazy(() => import("@/features/browser-bridge/pages/BrowserBridge"));
|
||||
const SkillLibrary = React.lazy(() => import("@/features/skills/pages/SkillLibrary"));
|
||||
const CodexHandoffs = React.lazy(() => import("@/features/handoffs/pages/CodexHandoffs"));
|
||||
const BlueprintEngine = React.lazy(() => import("@/features/blueprint/pages/BlueprintEngine"));
|
||||
const RepoBuilder = React.lazy(() => import("@/features/repo-builder/pages/RepoBuilder"));
|
||||
const DevelopmentWorkbench = React.lazy(() => import("@/features/development/pages/DevelopmentWorkbench"));
|
||||
const ProjectAuditCenter = React.lazy(() => import("@/features/audit/pages/ProjectAuditCenter"));
|
||||
const BrainstormLab = React.lazy(() => import("@/features/brainstorm/pages/BrainstormLab"));
|
||||
const OperatingLog = React.lazy(() => import("@/features/operations/pages/OperatingLog"));
|
||||
const Settings = React.lazy(() => import("@/features/settings/pages/Settings"));
|
||||
const PortfolioHome = React.lazy(() => import("@/features/portfolio/pages/PortfolioHome"));
|
||||
const PortfolioCommandCenter = React.lazy(() => import("@/features/portfolio/pages/PortfolioCommandCenter"));
|
||||
|
||||
const RouteLoadingFallback = () => (
|
||||
<div className="space-y-4" aria-label="ProjectBrain-pagina laden">
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="h-6 w-28 animate-pulse rounded-full bg-muted" />
|
||||
<div className="h-6 w-40 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="mt-4 h-8 w-64 max-w-full animate-pulse rounded-md bg-muted" />
|
||||
<div className="mt-3 h-4 w-full max-w-2xl animate-pulse rounded-md bg-muted" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Project laden…</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="h-28 animate-pulse rounded-lg border bg-muted/40" />
|
||||
<div className="h-28 animate-pulse rounded-lg border bg-muted/40" />
|
||||
<div className="h-28 animate-pulse rounded-lg border bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const lazyElement = (page: React.ReactNode) => <React.Suspense fallback={<RouteLoadingFallback />}>{page}</React.Suspense>;
|
||||
const ProjectPageScope = ({ children }: { children: React.ReactNode }) => {
|
||||
const { projectId } = useParams();
|
||||
return <React.Fragment key={projectId}>{lazyElement(children)}</React.Fragment>;
|
||||
};
|
||||
const projectElement = (page: React.ReactNode) => <AppLayout><ProjectPageScope>{page}</ProjectPageScope></AppLayout>;
|
||||
|
||||
const LocalLabBoundary = ({ children }: { children: React.ReactNode }) => {
|
||||
const { projectId } = useParams();
|
||||
if (projectBrainClientMode !== "real") return children;
|
||||
return <div className="mx-auto max-w-2xl rounded-xl border border-amber-300 bg-amber-50 p-6 text-amber-950">
|
||||
<h1 className="text-xl font-semibold">Deze technische labmodule is niet actief</h1>
|
||||
<p className="mt-2 text-sm leading-6">Deze oudere expertmodule gebruikt nog lokale voorbeelddata. ProjectBrain blokkeert hier bewust opslagacties zodat er geen schijnbare wijzigingen ontstaan.</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2"><Button asChild><Link to={`/projects/${projectId}/workspace`}>Opdracht geven</Link></Button><Button asChild variant="outline"><Link to={`/projects/${projectId}/history`}>Activiteit bekijken</Link></Button></div>
|
||||
</div>;
|
||||
};
|
||||
const labElement = (page: React.ReactNode) => projectElement(<LocalLabBoundary>{page}</LocalLabBoundary>);
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const location = useLocation();
|
||||
React.useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const main = document.querySelector('[data-projectbrain-main="true"]');
|
||||
let focusMain = false;
|
||||
try {
|
||||
focusMain = window.sessionStorage.getItem("projectbrain.route-visited.v1") === "true";
|
||||
window.sessionStorage.setItem("projectbrain.route-visited.v1", "true");
|
||||
} catch {
|
||||
// A blocked session store should not prevent the page from loading.
|
||||
}
|
||||
resetApplicationScroll(main instanceof HTMLElement ? main : undefined, window, focusMain);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [location.pathname]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const ProjectRouteSync = () => {
|
||||
const location = useLocation();
|
||||
const { activeProjectId, projects, setActiveProjectId } = useProject();
|
||||
const routeProjectId = React.useMemo(() => {
|
||||
const match = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||
const projectId = match?.[1] ? decodeURIComponent(match[1]) : "";
|
||||
return projectId && projectId !== "new" ? projectId : "";
|
||||
}, [location.pathname]);
|
||||
const routeProjectExists = React.useMemo(() => projects.some((project) => project.id === routeProjectId), [projects, routeProjectId]);
|
||||
React.useEffect(() => {
|
||||
if (routeProjectId && routeProjectExists && routeProjectId !== activeProjectId) setActiveProjectId(routeProjectId);
|
||||
}, [activeProjectId, routeProjectExists, routeProjectId, setActiveProjectId]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const ProjectHomeRedirect = () => {
|
||||
const { projectId } = useParams();
|
||||
const { projects, projectDataLoading } = useProject();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const [loading, setLoading] = React.useState(projectBrainClientMode === "real");
|
||||
const [route, setRoute] = React.useState<string>();
|
||||
const loadGeneration = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projectDataLoading) return;
|
||||
const generation = ++loadGeneration.current;
|
||||
if (!projectId || !project) {
|
||||
setLoading(false);
|
||||
setRoute(undefined);
|
||||
return;
|
||||
}
|
||||
if (projectBrainClientMode !== "real") {
|
||||
setRoute(resolveProjectResume({ projectId, project }).route);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
void Promise.allSettled([
|
||||
import("@/shared/api/realClient").then(({ realClient }) => realClient.initiatives.listFeatureDeliveries(projectId).then((response) => response.data)),
|
||||
import("@/shared/api/autonomyApi").then(({ autonomyApi }) => autonomyApi.get(projectId)),
|
||||
]).then(([deliveryResult, autoResult]) => {
|
||||
if (generation !== loadGeneration.current) return;
|
||||
const deliveries = deliveryResult.status === "fulfilled" ? deliveryResult.value : [];
|
||||
const autoState = autoResult.status === "fulfilled" ? autoResult.value : undefined;
|
||||
const attentionCount = project.resourceCounts?.attentionItems ?? 0;
|
||||
setRoute(resolveProjectResume({
|
||||
projectId,
|
||||
project,
|
||||
deliveries,
|
||||
autoState,
|
||||
attentionRequired: attentionCount > 0 ? true : undefined,
|
||||
attentionDetail: attentionCount ? `${attentionCount} aandachtspunt${attentionCount === 1 ? " wacht" : "en wachten"} op afhandeling.` : undefined,
|
||||
}).route);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { loadGeneration.current += 1; };
|
||||
}, [project, projectDataLoading, projectId]);
|
||||
|
||||
if (projectDataLoading || loading) return <RouteLoadingFallback />;
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
return <Navigate to={route ?? projectRoutePath(project.id, "overview")} replace />;
|
||||
};
|
||||
|
||||
const DefaultHomeRedirect = () => {
|
||||
const { activeProjectId, projectDataLoading } = useProject();
|
||||
if (projectDataLoading) return <RouteLoadingFallback />;
|
||||
if (projectBrainClientMode === "real") return <Navigate to="/portfolio" replace />;
|
||||
if (activeProjectId) return <Navigate to={projectRoutePath(activeProjectId, "workspace")} replace />;
|
||||
return <Navigate to="/projects" replace />;
|
||||
};
|
||||
|
||||
export const AppRoutes = () => (
|
||||
<ProjectProvider>
|
||||
<ScrollToTop />
|
||||
<ProjectRouteSync />
|
||||
<Routes>
|
||||
<Route path="/" element={<DefaultHomeRedirect />} />
|
||||
<Route path="/start" element={projectElement(<GuidedStart />)} />
|
||||
<Route path="/portfolio" element={projectElement(<PortfolioHome />)} />
|
||||
<Route path="/platform" element={projectElement(<PortfolioCommandCenter />)} />
|
||||
<Route path="/projects" element={projectElement(<Projects />)} />
|
||||
<Route path="/projects/new" element={projectElement(<ProjectSetupFlow />)} />
|
||||
<Route path="/projects/:projectId" element={<ProjectHomeRedirect />} />
|
||||
<Route path={projectRoutePattern("workspace")} element={projectElement(<Workspace />)} />
|
||||
<Route path={projectRoutePattern("overview")} element={projectElement(<ProjectOverview />)} />
|
||||
<Route path={projectRoutePattern("attention")} element={projectElement(<Initiatives />)} />
|
||||
<Route path={projectRoutePattern("results")} element={projectElement(<ProjectResults />)} />
|
||||
<Route path="/projects/:projectId/dna" element={projectElement(<ProjectDNA />)} />
|
||||
<Route path="/projects/:projectId/runtime" element={projectElement(<RuntimeReadiness />)} />
|
||||
<Route path="/projects/:projectId/analysis-jobs" element={projectElement(<RepositoryAnalysisJobs />)} />
|
||||
<Route path="/projects/:projectId/findings" element={projectElement(<FindingInbox />)} />
|
||||
<Route path="/projects/:projectId/context-packs" element={projectElement(<ContextPacks />)} />
|
||||
<Route path="/projects/:projectId/context-packs/:packId" element={projectElement(<ContextPacks />)} />
|
||||
<Route path="/projects/:projectId/capture" element={projectElement(<CaptureCenter />)} />
|
||||
<Route path="/projects/:projectId/source-library" element={projectElement(<ExtraSources />)} />
|
||||
<Route path="/projects/:projectId/source-library-technical" element={projectElement(<SourceLibrary />)} />
|
||||
<Route path="/projects/:projectId/brainstorm" element={projectElement(<BrainstormLab />)} />
|
||||
<Route path="/projects/:projectId/review-queue" element={projectElement(<ReviewQueue />)} />
|
||||
<Route path="/projects/:projectId/ai-actions" element={labElement(<AIActionCenter />)} />
|
||||
<Route path="/projects/:projectId/initiatives" element={projectElement(<Initiatives />)} />
|
||||
<Route path="/projects/:projectId/initiatives/:initiativeId" element={projectElement(<InitiativeDetail />)} />
|
||||
<Route path="/projects/:projectId/documentation" element={projectElement(<DocumentationHub />)} />
|
||||
<Route path="/projects/:projectId/blueprint" element={projectElement(<BlueprintEngine />)} />
|
||||
<Route path="/projects/:projectId/repo-builder" element={projectElement(<RepoBuilder />)} />
|
||||
<Route path="/projects/:projectId/develop" element={projectElement(<DevelopmentWorkbench />)} />
|
||||
<Route path="/projects/:projectId/audit" element={projectElement(<ProjectAuditCenter />)} />
|
||||
<Route path="/projects/:projectId/operating-log" element={projectElement(<OperatingLog />)} />
|
||||
<Route path={projectRoutePattern("activity")} element={projectElement(<ProjectActivity />)} />
|
||||
<Route path="/projects/:projectId/history-technical" element={projectElement(<ProjectHistory />)} />
|
||||
<Route path="/projects/:projectId/release" element={projectElement(<ReleaseCenter />)} />
|
||||
<Route path="/projects/:projectId/build-loop" element={projectElement(<BuildLoopDashboard />)} />
|
||||
<Route path="/projects/:projectId/auto" element={projectElement(<AutoMode />)} />
|
||||
<Route path="/projects/:projectId/execution" element={projectElement(<ExecutionCenter />)} />
|
||||
<Route path="/projects/:projectId/execution-feedback" element={projectElement(<ExecutionFeedback />)} />
|
||||
<Route path="/projects/:projectId/session-packs" element={projectElement(<SessionPacks />)} />
|
||||
<Route path="/projects/:projectId/browser-bridge" element={labElement(<BrowserBridge />)} />
|
||||
<Route path="/projects/:projectId/codex-handoffs" element={projectElement(<CodexHandoffs />)} />
|
||||
<Route path="/projects/:projectId/handoffs" element={projectElement(<CodexHandoffs />)} />
|
||||
<Route path="/projects/:projectId/skills" element={projectElement(<SkillLibrary />)} />
|
||||
<Route path="/projects/:projectId/insights" element={projectElement(<Insights />)} />
|
||||
<Route path="/projects/:projectId/knowledge" element={projectElement(<KnowledgeVault />)} />
|
||||
<Route path="/projects/:projectId/architecture" element={projectElement(<ArchitectureMap />)} />
|
||||
<Route path="/projects/:projectId/features" element={projectElement(<FeatureMap />)} />
|
||||
<Route path="/projects/:projectId/features/:featureId" element={projectElement(<FeatureDetail />)} />
|
||||
<Route path="/projects/:projectId/checks" element={labElement(<AIReview />)} />
|
||||
<Route path="/projects/:projectId/review-board" element={labElement(<AIReview />)} />
|
||||
<Route path="/projects/:projectId/memory" element={projectElement(<MemoryRules />)} />
|
||||
<Route path="/projects/:projectId/memory-rules" element={projectElement(<MemoryRules />)} />
|
||||
<Route path="/projects/:projectId/prompts" element={projectElement(<PromptGenerator />)} />
|
||||
<Route path="/projects/:projectId/builds" element={labElement(<BuildJournal />)} />
|
||||
<Route path="/projects/:projectId/build-journal" element={labElement(<BuildJournal />)} />
|
||||
<Route path="/projects/:projectId/regression-memory" element={labElement(<RegressionMemory />)} />
|
||||
<Route path="/projects/:projectId/regressions" element={labElement(<RegressionMemory />)} />
|
||||
<Route path="/projects/:projectId/relationships" element={projectElement(<Relationships />)} />
|
||||
<Route path="/projects/:projectId/snapshots" element={labElement(<Snapshots />)} />
|
||||
<Route path="/projects/:projectId/snapshots/:snapshotId" element={labElement(<SnapshotDetail />)} />
|
||||
<Route path="/projects/:projectId/tasks" element={labElement(<Tasks />)} />
|
||||
<Route path="/projects/:projectId/bugs" element={labElement(<Bugs />)} />
|
||||
<Route path="/projects/:projectId/decisions" element={labElement(<Decisions />)} />
|
||||
<Route path="/settings" element={projectElement(<Settings />)} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</ProjectProvider>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resetApplicationScroll } from "./scrollReset";
|
||||
|
||||
describe("application scroll reset", () => {
|
||||
it("resets both the app scroller and viewport before moving focus", () => {
|
||||
const calls: string[] = [];
|
||||
const main = {
|
||||
scrollTo: vi.fn(() => calls.push("main-scroll")),
|
||||
focus: vi.fn(() => calls.push("main-focus")),
|
||||
};
|
||||
const viewport = {
|
||||
scrollTo: vi.fn(() => calls.push("viewport-scroll")),
|
||||
};
|
||||
|
||||
resetApplicationScroll(main, viewport);
|
||||
|
||||
expect(main.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
|
||||
expect(viewport.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
|
||||
expect(main.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(calls).toEqual(["main-scroll", "viewport-scroll", "main-focus"]);
|
||||
});
|
||||
|
||||
it("still resets the viewport before the layout scroller exists", () => {
|
||||
const viewport = { scrollTo: vi.fn() };
|
||||
|
||||
resetApplicationScroll(undefined, viewport);
|
||||
|
||||
expect(viewport.scrollTo).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
type ScrollOptionsTarget = {
|
||||
scrollTo: (options: ScrollToOptions) => void;
|
||||
};
|
||||
|
||||
type FocusTarget = ScrollOptionsTarget & {
|
||||
focus: (options?: FocusOptions) => void;
|
||||
};
|
||||
|
||||
const topLeft: ScrollToOptions = { top: 0, left: 0, behavior: "auto" };
|
||||
|
||||
export const resetApplicationScroll = (
|
||||
main: FocusTarget | undefined,
|
||||
viewport: ScrollOptionsTarget,
|
||||
focusMain = true,
|
||||
) => {
|
||||
main?.scrollTo(topLeft);
|
||||
viewport.scrollTo(topLeft);
|
||||
if (focusMain) main?.focus({ preventScroll: true });
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AppLayout = ({ children }: AppLayoutProps) => {
|
||||
return (
|
||||
<div className="flex h-screen h-dvh w-full min-w-0 overflow-hidden bg-background">
|
||||
<a
|
||||
href="#projectbrain-main"
|
||||
className="fixed left-4 top-4 z-[100] -translate-y-24 rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground shadow-lg transition-transform focus:translate-y-0"
|
||||
>
|
||||
Ga naar hoofdinhoud
|
||||
</a>
|
||||
<Sidebar />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main id="projectbrain-main" tabIndex={-1} data-projectbrain-main="true" className="w-full min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-28 pt-6 outline-none md:px-8 md:pt-8 lg:px-12 lg:pb-10 lg:pt-10">
|
||||
<div className="mx-auto w-full min-w-0 max-w-[1380px] animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Activity, ArrowRight, Bot, CheckSquare, FileText, History, ListTodo, RefreshCw, Search, ShieldAlert, Sparkles } from "lucide-react";
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@/components/ui/command";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { getNavigationGroups, getProjectIdFromPath, type NavigationItem } from "@/shared/lib/navigation";
|
||||
import { getCommandResultKey, rankCommandResults, rankNavigationItems } from "./commandMenuSearch";
|
||||
import { getProjectOperatingStep } from "@/shared/lib/projectOperatingStep";
|
||||
|
||||
const NavigationCommandItem = ({ item, onSelect }: { item: NavigationItem; onSelect: (path: string) => void }) => {
|
||||
const Icon = item.icon;
|
||||
return <CommandItem value={`${item.workflow} ${item.label} ${item.description} ${item.searchTerms ?? ""}`} onSelect={() => onSelect(item.path)}><Icon className="mr-2 h-4 w-4" /><div className="min-w-0"><span className="block truncate">{item.label}</span><span className="block truncate text-xs text-muted-foreground">{item.description}</span></div></CommandItem>;
|
||||
};
|
||||
|
||||
export function CommandMenu() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { projects, searchProjectResults, searchGlobalResults } = useProject();
|
||||
const navigationProjectId = getProjectIdFromPath(location.pathname);
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
const navigationGroups = React.useMemo(() => getNavigationGroups(navigationProjectId), [navigationProjectId]);
|
||||
const routeProject = React.useMemo(() => projects.find((project) => project.id === navigationProjectId), [navigationProjectId, projects]);
|
||||
const projectNameById = React.useMemo(() => new Map(projects.map((project) => [project.id, project.name])), [projects]);
|
||||
const nextStep = React.useMemo(() => routeProject ? getProjectOperatingStep(routeProject) : undefined, [routeProject]);
|
||||
const projectResults = React.useMemo(() => open && isSearching && navigationProjectId ? searchProjectResults(navigationProjectId) : [], [isSearching, navigationProjectId, open, searchProjectResults]);
|
||||
const allGlobalResults = React.useMemo(() => open && isSearching ? searchGlobalResults() : [], [isSearching, open, searchGlobalResults]);
|
||||
const activeProjectResultKeys = React.useMemo(() => new Set(projectResults.map(getCommandResultKey)), [projectResults]);
|
||||
const rankedProjectResults = React.useMemo(() => rankCommandResults(projectResults, searchQuery, 16), [projectResults, searchQuery]);
|
||||
const globalResults = React.useMemo(() => rankCommandResults(allGlobalResults, searchQuery, 10, activeProjectResultKeys), [activeProjectResultKeys, allGlobalResults, searchQuery]);
|
||||
const navigationSearchItems = React.useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
const uniqueItems = navigationGroups
|
||||
.flatMap((group) => group.items)
|
||||
.filter((item, index, items) => items.findIndex((candidate) => candidate.path === item.path) === index);
|
||||
return rankNavigationItems(uniqueItems, searchQuery, 8);
|
||||
}, [isSearching, navigationGroups, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); setOpen((value) => !value); }
|
||||
};
|
||||
window.addEventListener("keydown", down, { capture: true });
|
||||
return () => window.removeEventListener("keydown", down, { capture: true });
|
||||
}, []);
|
||||
|
||||
const runCommand = (command: () => void) => { setOpen(false); setSearchQuery(""); command(); };
|
||||
const navigateTo = (path: string) => runCommand(() => navigate(path));
|
||||
const handleOpenChange = (value: boolean) => { setOpen(value); if (!value) setSearchQuery(""); };
|
||||
const searchPlaceholder = navigationProjectId ? "Zoek op repo, review, contextpack, historiek, bron of skill…" : "Zoek project, pagina, instelling of route…";
|
||||
const navigationSearchGroup = navigationSearchItems.length ? <CommandGroup heading="Beste overeenkomsten">{navigationSearchItems.map((item) => <NavigationCommandItem key={item.path} item={item} onSelect={navigateTo} />)}</CommandGroup> : null;
|
||||
|
||||
return <>
|
||||
<button onClick={() => { setSearchQuery(""); setOpen(true); }} aria-label="Open zoek- en opdrachtenmenu" className="relative inline-flex h-11 w-11 items-center justify-center rounded-md border bg-muted/50 p-0 text-sm font-normal text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:w-full md:max-w-md md:justify-start md:px-3"><Search className="h-4 w-4 shrink-0 md:mr-2" /><span className="hidden truncate md:inline">Zoek of voer een projectactie uit…</span><kbd className="pointer-events-none absolute right-1.5 top-2.5 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium lg:flex"><span className="text-xs">Ctrl</span>+K</kbd></button>
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<CommandInput value={searchQuery} onValueChange={setSearchQuery} placeholder={searchPlaceholder} aria-label="Zoek pagina's, projectitems en opdrachten" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Geen resultaten gevonden.</CommandEmpty>
|
||||
{!isSearching ? <>
|
||||
{nextStep ? <CommandGroup heading={`Volgende stap in ${routeProject?.name ?? "project"}`}><CommandItem value={`volgende stap ${nextStep.label} ${nextStep.description}`} onSelect={() => navigateTo(nextStep.route)}><ArrowRight className="mr-2 h-4 w-4 text-primary" /><div className="min-w-0"><span className="block truncate font-medium">{nextStep.label}</span><span className="block truncate text-xs text-muted-foreground">{nextStep.description}</span></div></CommandItem></CommandGroup> : null}
|
||||
{navigationProjectId ? <><CommandSeparator /><CommandGroup heading="Dit project"><CommandItem value="overview current state project" onSelect={() => navigateTo(`/projects/${navigationProjectId}`)}><Bot className="mr-2 h-4 w-4" />Projectoverzicht</CommandItem><CommandItem value="assignment opdracht geven work" onSelect={() => navigateTo(`/projects/${navigationProjectId}/workspace`)}><ListTodo className="mr-2 h-4 w-4" />Opdracht geven</CommandItem><CommandItem value="attention decision blocker human" onSelect={() => navigateTo(`/projects/${navigationProjectId}/attention`)}><CheckSquare className="mr-2 h-4 w-4" />Jouw aandacht</CommandItem><CommandItem value="activity running execution" onSelect={() => navigateTo(`/projects/${navigationProjectId}/activity`)}><Activity className="mr-2 h-4 w-4" />Actieve uitvoering & activiteit</CommandItem><CommandItem value="results delivered proof" onSelect={() => navigateTo(`/projects/${navigationProjectId}/results`)}><History className="mr-2 h-4 w-4" />Laatste resultaten</CommandItem><CommandItem value="auto pause resume recovery" onSelect={() => navigateTo(`/projects/${navigationProjectId}/auto`)}><RefreshCw className="mr-2 h-4 w-4" />Auto Mode</CommandItem><CommandItem value="diagnostics system health" onSelect={() => navigateTo(`/projects/${navigationProjectId}/runtime`)}><ShieldAlert className="mr-2 h-4 w-4" />Systeemstatus</CommandItem></CommandGroup></> : <CommandGroup heading="ProjectBrain"><CommandItem value="portfolio home" onSelect={() => navigateTo("/portfolio")}><Bot className="mr-2 h-4 w-4" />Home</CommandItem><CommandItem value="projects existing" onSelect={() => navigateTo("/projects")}><Search className="mr-2 h-4 w-4" />Projecten</CommandItem><CommandItem value="new project start" onSelect={() => navigateTo("/start")}><Sparkles className="mr-2 h-4 w-4" />Nieuw project</CommandItem><CommandItem value="system diagnostics" onSelect={() => navigateTo("/platform#portfolio-diagnostics")}><ShieldAlert className="mr-2 h-4 w-4" />Platformdiagnose</CommandItem></CommandGroup>}
|
||||
<CommandSeparator /><CommandGroup heading="Project wisselen"><CommandItem value="projects switch project list" onSelect={() => navigateTo("/projects")}><Search className="mr-2 h-4 w-4" /><span>Bestaande projecten openen</span><CommandShortcut>Projecten</CommandShortcut></CommandItem><CommandItem value="start new project" onSelect={() => navigateTo("/start")}><Sparkles className="mr-2 h-4 w-4" /><span>Nieuw project starten</span><CommandShortcut>Start</CommandShortcut></CommandItem></CommandGroup>
|
||||
</> : <>
|
||||
{navigationSearchGroup}
|
||||
{rankedProjectResults.length ? <><CommandSeparator /><CommandGroup heading="In dit project">{rankedProjectResults.map((result) => <CommandItem key={`${result.type}-${result.id}`} value={`${result.type} ${result.label} ${result.description}`} onSelect={() => navigateTo(result.route)}><FileText className="mr-2 h-4 w-4" /><div className="min-w-0"><span className="block truncate">{result.label}</span><span className="block truncate text-xs text-muted-foreground">{result.description}</span></div></CommandItem>)}</CommandGroup></> : null}
|
||||
{globalResults.length ? <><CommandSeparator /><CommandGroup heading={navigationProjectId ? "Andere projecten" : "Projectresultaten"}>{globalResults.map((result) => <CommandItem key={`global-${result.projectId}-${result.type}-${result.id}`} value={`${result.projectId} ${result.label} ${result.description}`} onSelect={() => navigateTo(result.route)}><Search className="mr-2 h-4 w-4" /><div className="min-w-0"><span className="block truncate">{result.label}</span><span className="block truncate text-xs text-muted-foreground">{projectNameById.get(result.projectId) ?? "Project"}</span></div></CommandItem>)}</CommandGroup></> : null}
|
||||
</>}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AlertCircle, AlertTriangle, Bell, Check, CheckCircle2, ChevronDown, Database, FolderKanban, Info, Plus, Server, Star, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useOptionalProject } from "@/shared/contexts/ProjectContext";
|
||||
import { CommandMenu } from "./CommandMenu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getNavigationBreadcrumbs, getNavigationItemForPath, getProjectIdFromPath } from "@/shared/lib/navigation";
|
||||
import { useBackendSystemReadinessData, usePortfolioControlPlaneData } from "@/shared/hooks/useProjectData";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
||||
|
||||
const readinessTone = {
|
||||
ready: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300",
|
||||
degraded: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
blocked: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300",
|
||||
unknown: "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300",
|
||||
} as const;
|
||||
|
||||
const readinessLabel = {
|
||||
ready: "Systeem gereed",
|
||||
degraded: "Controle nodig",
|
||||
blocked: "Geblokkeerd",
|
||||
unknown: "Controleren",
|
||||
} as const;
|
||||
|
||||
const workflowLabel = {
|
||||
Home: "Overzicht",
|
||||
Blueprint: "Ontwerp",
|
||||
Intake: "Invoer",
|
||||
Knowledge: "Kennis",
|
||||
Delivery: "Uitvoering",
|
||||
System: "Systeem",
|
||||
} as const;
|
||||
|
||||
const READ_NOTIFICATIONS_KEY = "projectbrain:notifications:read:v1";
|
||||
|
||||
const Header = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const projectContext = useOptionalProject();
|
||||
const projects = React.useMemo(() => projectContext?.projects ?? [], [projectContext?.projects]);
|
||||
const headerNotifications = projectContext?.headerNotifications ?? [];
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [projectPickerOpen, setProjectPickerOpen] = React.useState(false);
|
||||
const [favoriteProjectIds, setFavoriteProjectIds] = React.useState<string[]>(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem("projectbrain.favoriteProjects.v1") ?? "[]") as unknown;
|
||||
return Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [readNotificationIds, setReadNotificationIds] = React.useState<string[]>(() => {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(READ_NOTIFICATIONS_KEY) ?? "[]") as unknown;
|
||||
return Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const routeProjectId = getProjectIdFromPath(location.pathname);
|
||||
const routeProject = routeProjectId ? projects.find((project) => project.id === routeProjectId) : undefined;
|
||||
const activeProject = routeProjectId ? routeProject : projectContext?.activeProject;
|
||||
const navigationProjectId = routeProjectId;
|
||||
const favoriteProjectIdSet = React.useMemo(() => new Set(favoriteProjectIds), [favoriteProjectIds]);
|
||||
const orderedProjects = React.useMemo(() => [...projects].sort((left, right) => {
|
||||
if (left.id === activeProject?.id) return -1;
|
||||
if (right.id === activeProject?.id) return 1;
|
||||
return Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.name.localeCompare(right.name);
|
||||
}), [activeProject?.id, projects]);
|
||||
const favoriteProjects = orderedProjects.filter((project) => favoriteProjectIdSet.has(project.id));
|
||||
const recentProjects = orderedProjects.filter((project) => !favoriteProjectIdSet.has(project.id)).slice(0, 5);
|
||||
const remainingProjects = orderedProjects.filter((project) => !favoriteProjectIdSet.has(project.id) && !recentProjects.some((recent) => recent.id === project.id));
|
||||
const currentNavigationItem = getNavigationItemForPath(location.pathname, navigationProjectId);
|
||||
const breadcrumbs = getNavigationBreadcrumbs(location.pathname, navigationProjectId, routeProject?.name);
|
||||
const systemReadiness = useBackendSystemReadinessData();
|
||||
const portfolioResource = usePortfolioControlPlaneData();
|
||||
const portfolioData = portfolioResource.data;
|
||||
const reloadPortfolio = portfolioResource.reload;
|
||||
const readiness = systemReadiness.data;
|
||||
const isRuntimeUnavailable = !readiness && !systemReadiness.isLoading;
|
||||
const readinessStatus = readiness?.status ?? "unknown";
|
||||
const runtimeStatusLabel = isRuntimeUnavailable ? "Lokale modus" : readinessLabel[readinessStatus];
|
||||
const failingChecks = readiness?.checks.filter((check) => check.status === "fail") ?? [];
|
||||
const warningChecks = readiness?.checks.filter((check) => check.status === "warn") ?? [];
|
||||
React.useEffect(() => {
|
||||
const refreshVisibleState = () => {
|
||||
if (document.visibilityState === "visible") reloadPortfolio();
|
||||
};
|
||||
const timer = window.setInterval(refreshVisibleState, 60_000);
|
||||
document.addEventListener("visibilitychange", refreshVisibleState);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleState);
|
||||
};
|
||||
}, [reloadPortfolio]);
|
||||
const notifications = portfolioData?.notifications.map((notification) => ({
|
||||
...notification,
|
||||
time: new Date(notification.occurredAt).toLocaleString("nl-BE", { dateStyle: "short", timeStyle: "short" }),
|
||||
})) ?? headerNotifications.map((notification) => ({
|
||||
id: notification.id,
|
||||
severity: notification.icon === "alert" ? "warning" as const : notification.icon === "check" ? "info" as const : "info" as const,
|
||||
title: notification.title,
|
||||
description: notification.description,
|
||||
route: activeProject ? `/projects/${activeProject.id}/history` : "/projects",
|
||||
time: notification.time,
|
||||
}));
|
||||
const unreadNotifications = notifications.filter((notification) => !readNotificationIds.includes(notification.id));
|
||||
|
||||
const persistReadNotifications = (ids: string[]) => {
|
||||
setReadNotificationIds(ids);
|
||||
try {
|
||||
window.localStorage.setItem(READ_NOTIFICATIONS_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
// Reading notifications remains available for this session when storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const markNotificationRead = (notificationId: string) => {
|
||||
if (readNotificationIds.includes(notificationId)) return;
|
||||
persistReadNotifications([...readNotificationIds, notificationId]);
|
||||
};
|
||||
|
||||
const selectProject = (project: (typeof projects)[number]) => {
|
||||
projectContext?.setActiveProject(project);
|
||||
projectContext?.setActiveProjectId(project.id);
|
||||
setProjectPickerOpen(false);
|
||||
navigate(projectEntryPath(project.id));
|
||||
};
|
||||
|
||||
const toggleFavoriteProject = (projectId: string) => {
|
||||
setFavoriteProjectIds((current) => {
|
||||
const next = current.includes(projectId) ? current.filter((id) => id !== projectId) : [...current, projectId];
|
||||
try {
|
||||
window.localStorage.setItem("projectbrain.favoriteProjects.v1", JSON.stringify(next));
|
||||
} catch {
|
||||
// Favorites remain available for this session when storage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderProjectItem = (project: (typeof projects)[number]) => (
|
||||
<CommandItem key={project.id} value={`${project.name} ${project.description} ${project.domain}`} onSelect={() => selectProject(project)} className="gap-2 py-2.5">
|
||||
<FolderKanban size={15} className="shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||
{activeProject?.id === project.id ? <Check size={14} className="text-primary" aria-label="Actief project" /> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-11 items-center justify-center rounded text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
aria-label={favoriteProjectIdSet.has(project.id) ? `${project.name} uit favorieten verwijderen` : `${project.name} aan favorieten toevoegen`}
|
||||
onClick={(event) => { event.preventDefault(); event.stopPropagation(); toggleFavoriteProject(project.id); }}
|
||||
>
|
||||
<Star size={14} fill={favoriteProjectIdSet.has(project.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex min-h-16 items-center justify-between gap-2 border-b bg-background/88 px-2 py-2 backdrop-blur-md sm:px-4 lg:px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
|
||||
<Popover open={projectPickerOpen} onOpenChange={setProjectPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-11 min-w-0 max-w-[34vw] justify-start gap-2 px-2 font-semibold sm:max-w-[240px] sm:px-3"
|
||||
aria-label={`Project kiezen. ${routeProject ? `Huidig project: ${routeProject.name}` : "Alle projecten"}`}
|
||||
>
|
||||
<span className="truncate">{routeProject?.name ?? (routeProjectId ? "Project niet gevonden" : "Alle projecten")}</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[min(92vw,380px)] p-0">
|
||||
<Command>
|
||||
<CommandInput aria-label="Project zoeken" placeholder="Zoek op project, omschrijving of domein…" />
|
||||
<CommandList className="max-h-[min(60vh,420px)]">
|
||||
<CommandEmpty>Geen projecten gevonden.</CommandEmpty>
|
||||
{favoriteProjects.length ? <CommandGroup heading="Favorieten">{favoriteProjects.map(renderProjectItem)}</CommandGroup> : null}
|
||||
{favoriteProjects.length ? <CommandSeparator /> : null}
|
||||
{recentProjects.length ? <CommandGroup heading="Recent">{recentProjects.map(renderProjectItem)}</CommandGroup> : null}
|
||||
{remainingProjects.length ? <><CommandSeparator /><CommandGroup heading="Alle andere projecten">{remainingProjects.map(renderProjectItem)}</CommandGroup></> : null}
|
||||
</CommandList>
|
||||
<div className="border-t p-2">
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 text-primary" onClick={() => { setProjectPickerOpen(false); navigate("/start"); }}>
|
||||
<Plus size={15} /> Nieuw project starten
|
||||
</Button>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{currentNavigationItem ? (
|
||||
<div className="min-w-0 flex-1 border-l pl-2 xl:hidden">
|
||||
<p className="truncate text-xs font-semibold sm:text-sm">
|
||||
{currentNavigationItem.shortLabel ?? currentNavigationItem.label}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentNavigationItem && (
|
||||
<div className="hidden min-w-0 border-l pl-4 xl:block">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">
|
||||
{workflowLabel[currentNavigationItem.workflow]}
|
||||
</Badge>
|
||||
<p className="truncate text-sm font-semibold">{currentNavigationItem.label}</p>
|
||||
</div>
|
||||
<nav aria-label="Broodkruimel" className="mt-0.5 flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={`${crumb.path}-${index}`}>
|
||||
{index > 0 && <span aria-hidden="true" className="text-muted-foreground">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
className="truncate hover:text-foreground"
|
||||
aria-label={`Ga naar ${crumb.label}`}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectContext ? <div className="w-11 shrink-0 md:min-w-[180px] md:max-w-md md:flex-1">
|
||||
<CommandMenu />
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" className={`hidden h-9 gap-2 rounded-full border px-3 text-xs font-medium lg:inline-flex ${readinessTone[readinessStatus]}`}>
|
||||
<Server size={15} aria-hidden="true" />
|
||||
<span>{runtimeStatusLabel}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Runtimestatus</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{isRuntimeUnavailable
|
||||
? "De frontend gebruikt lokale projectdata; backend-readiness is niet gekoppeld."
|
||||
: "Live status van backend, database en migraties."}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={readinessStatus === "ready" ? "default" : "outline"}>{runtimeStatusLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Databron</p>
|
||||
<p className="font-semibold">{readiness?.dataSource ?? "unknown"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Database</p>
|
||||
<p className="font-semibold">{readiness?.database.reachable ? "bereikbaar" : "niet bevestigd"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Projecten</p>
|
||||
<p className="font-semibold">{readiness?.database.projectCount ?? "niet gekoppeld"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Migraties</p>
|
||||
<p className="font-semibold">{readiness?.database.migrationCount ?? "niet gekoppeld"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isRuntimeUnavailable ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">
|
||||
<p className="font-medium">Lokale voorbeeldmodus is actief.</p>
|
||||
<p className="mt-1 text-xs">De interface blijft bruikbaar met lokale data. Start de backend voor live database-readiness.</p>
|
||||
</div>
|
||||
) : (failingChecks.length > 0 || warningChecks.length > 0) ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<p className="font-medium">Aandacht nodig</p>
|
||||
<p className="mt-1 text-xs">{failingChecks.length} mislukte controles, {warningChecks.length} waarschuwingen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
<p className="font-medium">De runtime reageert normaal.</p>
|
||||
<p className="mt-1 text-xs">API, databaseprobe en readinesscontroles zijn groen.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="flex-1" onClick={() => navigate(activeProject ? `/projects/${activeProject.id}/runtime` : "/projects")}>
|
||||
<Database size={14} />
|
||||
Runtimepagina
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="flex-1" onClick={() => systemReadiness.reload()}>
|
||||
Opnieuw controleren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative h-11 w-11" aria-label={unreadNotifications.length ? `Open meldingen (${unreadNotifications.length} ongelezen)` : "Open meldingen"}>
|
||||
<Bell size={18} aria-hidden="true" />
|
||||
{unreadNotifications.length > 0 ? <span aria-hidden="true" className="absolute -right-1 -top-1 flex min-h-5 min-w-5 items-center justify-center rounded-full border-2 border-background bg-primary px-1 text-[10px] font-semibold text-primary-foreground">{Math.min(unreadNotifications.length, 99)}</span> : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Meldingen</h2>
|
||||
{unreadNotifications.length ? <Button variant="ghost" size="sm" className="h-8 px-2 text-xs" onClick={() => persistReadNotifications(notifications.map((notification) => notification.id))}>Alles gelezen</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{notifications.length === 0 && <div className="p-4 text-xs text-muted-foreground">Geen open aandachtspunten. ProjectBrain blijft de portfolioflow bewaken.</div>}
|
||||
{notifications.map((notification) => {
|
||||
const Icon = notification.severity === "critical" ? AlertCircle : notification.severity === "warning" ? AlertTriangle : notification.id === "portfolio-healthy" ? CheckCircle2 : Info;
|
||||
const color = notification.severity === "critical" ? "text-red-600" : notification.severity === "warning" ? "text-amber-600" : "text-blue-600";
|
||||
return (
|
||||
<button type="button" key={notification.id} onClick={() => { markNotificationRead(notification.id); navigate(notification.route); }} className={`flex w-full gap-3 border-b p-4 text-left transition-colors last:border-0 hover:bg-muted/50 ${readNotificationIds.includes(notification.id) ? "opacity-65" : "bg-primary/[0.025]"}`}>
|
||||
<Icon className={`mt-0.5 shrink-0 ${color}`} size={16} />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{notification.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{notification.description}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{notification.time}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t p-2 text-center">
|
||||
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => navigate(projectBrainClientMode === "real" ? "/portfolio" : activeProject ? `/projects/${activeProject.id}/history` : "/projects")}>
|
||||
{projectBrainClientMode === "real" ? "Portfolio-actiewachtrij openen" : activeProject ? "Projectactiviteit openen" : "Projecten openen"}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-11 w-11 rounded-full" aria-label="Open accountmenu">
|
||||
<User size={18} aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{user?.username ?? "Mijn account"}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/settings")}>Instellingen</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => void logout()}>Afmelden</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { ArrowRight, Brain, ChevronDown, ChevronLeft, ChevronRight, MoreHorizontal, SlidersHorizontal } from "lucide-react";
|
||||
import { useOptionalProject } from "@/shared/contexts/ProjectContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useIsCompactSidebar, useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getAdvancedNavigationGroups, getNavigationGroups, getNavigationItemForPath, getPrimaryMobileNavigation, getPrimaryNavigationGroups, getProjectIdFromPath, isNavigationPathActive, type NavigationItem } from "@/shared/lib/navigation";
|
||||
import { getProjectOperatingStep } from "@/shared/lib/projectOperatingStep";
|
||||
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
||||
import { readProjectJson, writeProjectJson } from "@/shared/lib/browserStorage";
|
||||
|
||||
const TECHNICAL_NAVIGATION_KEY = "projectbrain:navigation:technical-details";
|
||||
|
||||
const NavigationLink = ({ item, isCollapsed, onItemClick }: { item: NavigationItem; isCollapsed?: boolean; onItemClick?: () => void }) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "/projects"}
|
||||
onClick={onItemClick}
|
||||
title={isCollapsed ? `${item.label} - ${item.description}` : undefined}
|
||||
aria-label={isCollapsed ? item.label : undefined}
|
||||
className={({ isActive }) => cn("group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", isActive ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" : "text-sidebar-foreground/86 hover:bg-sidebar-accent/55 hover:text-sidebar-accent-foreground")}
|
||||
>
|
||||
<item.icon size={18} className="shrink-0" aria-hidden="true" />
|
||||
{!isCollapsed ? <span className="min-w-0 flex-1"><span className="block truncate leading-tight">{item.label}</span><span className="mt-0.5 hidden truncate text-[10px] font-normal text-sidebar-foreground group-hover:text-sidebar-accent-foreground 2xl:block">{item.description}</span></span> : null}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const SidebarContent = ({ isCollapsed, onItemClick }: { isCollapsed?: boolean; onItemClick?: () => void }) => {
|
||||
const projectContext = useOptionalProject();
|
||||
const location = useLocation();
|
||||
const routeProjectId = getProjectIdFromPath(location.pathname);
|
||||
const activeProject = routeProjectId ? projectContext?.projects.find((project) => project.id === routeProjectId) : undefined;
|
||||
const groups = getNavigationGroups(routeProjectId);
|
||||
const primaryGroups = getPrimaryNavigationGroups(routeProjectId);
|
||||
const technicalGroups = getAdvancedNavigationGroups(routeProjectId);
|
||||
const staticProjectStep = activeProject ? getProjectOperatingStep(activeProject) : undefined;
|
||||
const sidebarAction = activeProject && (staticProjectStep?.tone === "blocked" || staticProjectStep?.tone === "watch")
|
||||
? { label: staticProjectStep.label, route: staticProjectStep.route }
|
||||
: activeProject
|
||||
? { label: "Actuele projectstatus bekijken", route: projectEntryPath(activeProject.id) }
|
||||
: undefined;
|
||||
const activeNavigationItem = getNavigationItemForPath(location.pathname, routeProjectId);
|
||||
const activeGroupId = groups.find((group) => group.items.some((item) => item.path === activeNavigationItem?.path))?.id;
|
||||
const activeTechnicalGroup = technicalGroups.find((group) => group.id === activeGroupId);
|
||||
const [technicalVisible, setTechnicalVisible] = React.useState(() => readProjectJson(TECHNICAL_NAVIGATION_KEY, false));
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set(["command-center", "project-management", "home", "application", activeGroupId].filter(Boolean) as string[]));
|
||||
const visibleGroups = isCollapsed
|
||||
? [...primaryGroups, ...(activeTechnicalGroup && activeNavigationItem ? [{ ...activeTechnicalGroup, items: activeTechnicalGroup.items.filter((item) => item.path === activeNavigationItem.path) }] : [])]
|
||||
: [...primaryGroups, ...(technicalVisible || activeTechnicalGroup ? technicalGroups : [])];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeGroupId) return;
|
||||
setExpandedGroups((current) => current.has(activeGroupId) ? current : new Set([...current, activeGroupId]));
|
||||
}, [activeGroupId]);
|
||||
|
||||
const toggleGroup = (groupId: string) => setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleTechnical = () => {
|
||||
const next = !technicalVisible;
|
||||
setTechnicalVisible(next);
|
||||
writeProjectJson(TECHNICAL_NAVIGATION_KEY, next);
|
||||
if (next) setExpandedGroups((current) => new Set([...current, ...technicalGroups.map((group) => group.id)]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-16 items-center justify-between border-b p-4">
|
||||
{!isCollapsed ? <div className="flex items-center gap-2 text-xl font-bold tracking-tight text-primary"><Brain className="h-6 w-6" /><span>ProjectBrain</span></div> : <Brain className="mx-auto h-8 w-8 text-primary" />}
|
||||
</div>
|
||||
|
||||
<nav aria-label="Hoofdnavigatie" className="flex-1 space-y-3 overflow-y-auto px-2 py-3">
|
||||
{!isCollapsed && activeProject && sidebarAction ? (
|
||||
<section className="rounded-lg border border-primary/20 bg-primary/5 p-2">
|
||||
<p className="px-2 text-[10px] font-semibold uppercase tracking-wide text-primary">{staticProjectStep?.tone === "blocked" || staticProjectStep?.tone === "watch" ? "Jouw aandacht" : `Actueel · ${activeProject.name}`}</p>
|
||||
<NavLink to={sidebarAction.route} onClick={onItemClick} className="mt-1 flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm font-semibold text-sidebar-foreground transition-colors hover:bg-sidebar-accent">
|
||||
<span className="truncate">{sidebarAction.label}</span><ArrowRight size={14} className="shrink-0 text-primary" />
|
||||
</NavLink>
|
||||
{staticProjectStep?.tone !== "blocked" && staticProjectStep?.tone !== "watch" ? <p className="px-2 pb-1 text-[10px] leading-4 text-sidebar-foreground">Overzicht combineert opdrachten, Auto-status en menselijke aandacht live.</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{visibleGroups.map((group) => (
|
||||
<section key={group.id} className="space-y-1">
|
||||
{!isCollapsed ? <button type="button" className="flex min-h-11 w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-sidebar-foreground hover:bg-sidebar-accent/50" onClick={() => toggleGroup(group.id)} aria-expanded={expandedGroups.has(group.id)}><span>{group.label}</span><ChevronDown size={14} className={cn("transition-transform", expandedGroups.has(group.id) && "rotate-180")} /></button> : null}
|
||||
<div className={cn("space-y-1", !isCollapsed && !expandedGroups.has(group.id) && "hidden")}>{group.items.map((item) => <NavigationLink key={item.path} item={item} isCollapsed={isCollapsed} onItemClick={onItemClick} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isCollapsed && technicalGroups.length ? (
|
||||
<button type="button" onClick={toggleTechnical} className="flex min-h-11 w-full items-center justify-between gap-3 rounded-lg border border-dashed px-3 py-2.5 text-left text-xs font-medium text-sidebar-foreground transition-colors hover:border-primary/40 hover:bg-sidebar-accent/50" aria-expanded={technicalVisible}>
|
||||
<span className="flex items-center gap-2"><SlidersHorizontal size={15} />{technicalVisible ? "Technische details verbergen" : "Technische details tonen"}</span>
|
||||
<span className="rounded bg-sidebar-accent px-1.5 py-0.5 text-[10px]">{technicalGroups.reduce((total, group) => total + group.items.length, 0)}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-2">
|
||||
{!isCollapsed ? <div className="flex items-center justify-between gap-2 rounded-lg bg-sidebar-accent/35 px-3 py-2"><div className="min-w-0"><p className="text-[10px] uppercase tracking-wide text-sidebar-foreground">Actief project</p><p className="truncate text-sm font-semibold text-sidebar-foreground">{activeProject?.name ?? "Geen project gekozen"}</p></div>{activeProject ? <NavLink to={projectEntryPath(activeProject.id)} aria-label={`Actuele status van ${activeProject.name} openen`} className="flex h-11 w-11 items-center justify-center rounded-md text-sidebar-foreground/80 hover:bg-sidebar-accent"><ArrowRight size={14} /></NavLink> : null}</div> : <div className="mx-auto h-2 w-2 rounded-full bg-primary" title={activeProject?.name ?? "Geen project"} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileBottomNavigation = () => {
|
||||
const location = useLocation();
|
||||
const routeProjectId = getProjectIdFromPath(location.pathname);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const moreButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const primaryItems = getPrimaryMobileNavigation(routeProjectId);
|
||||
const visibleItems = primaryItems.slice(0, 4);
|
||||
const hasPrimaryRouteMatch = primaryItems.some((item) => isNavigationPathActive(location.pathname, item.path));
|
||||
const isMoreActive = isOpen || !hasPrimaryRouteMatch || primaryItems.slice(4).some((item) => isNavigationPathActive(location.pathname, item.path));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent id="mobile-navigation-dialog" side="left" className="z-[60] w-80 max-w-[86vw] bg-sidebar p-0 lg:hidden" onCloseAutoFocus={(event) => { event.preventDefault(); moreButtonRef.current?.focus(); }}>
|
||||
<SheetHeader className="sr-only"><SheetTitle>Projectnavigatie</SheetTitle><SheetDescription>Kies een dagelijkse actie of open technische details.</SheetDescription></SheetHeader>
|
||||
<SidebarContent onItemClick={() => setIsOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<nav aria-label="Mobiele hoofdnavigatie" className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 px-2 pb-[max(env(safe-area-inset-bottom),0.5rem)] pt-2 shadow-[0_-8px_24px_rgba(15,23,42,0.10)] backdrop-blur lg:hidden">
|
||||
<div className="mx-auto grid max-w-md grid-cols-5 gap-1">
|
||||
{visibleItems.map((item) => <NavLink key={item.path} to={item.path} end={item.path === "/projects"} className={({ isActive }) => cn("flex min-h-11 flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[10px] font-medium", isActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}><item.icon size={18} aria-hidden="true" /><span className="truncate">{item.label}</span></NavLink>)}
|
||||
<button ref={moreButtonRef} type="button" aria-expanded={isOpen} aria-controls="mobile-navigation-dialog" aria-haspopup="dialog" aria-current={isMoreActive ? "page" : undefined} onClick={() => setIsOpen(true)} className={cn("flex min-h-11 flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[10px] font-medium", isMoreActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}><MoreHorizontal size={18} /><span>Meer</span></button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = () => {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isCompactSidebar = useIsCompactSidebar();
|
||||
if (isMobile) return <MobileBottomNavigation />;
|
||||
const collapsed = isCompactSidebar || isCollapsed;
|
||||
return (
|
||||
<aside aria-label="Projectnavigatie" className={cn("sticky top-0 flex h-screen flex-col border-r bg-sidebar transition-all duration-300", collapsed ? "w-16" : "w-72")}>
|
||||
<SidebarContent isCollapsed={collapsed} />
|
||||
{!isCompactSidebar ? <Button variant="ghost" size="icon" onClick={() => setIsCollapsed((current) => !current)} className="absolute -right-3 top-20 z-20 h-6 w-6 rounded-full border bg-background shadow-sm" aria-label={collapsed ? "Navigatie uitklappen" : "Navigatie inklappen"}>{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}</Button> : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SearchResult } from "@/shared/lib/projectSearch";
|
||||
import { getCommandResultKey, rankCommandResults, rankNavigationItems } from "./commandMenuSearch";
|
||||
|
||||
const result = (id: string, label: string, description = ""): SearchResult => ({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
projectId: "widefrog",
|
||||
route: `/projects/widefrog/history?entry=${id}`,
|
||||
type: "documentation",
|
||||
});
|
||||
|
||||
const navigationItem = (label: string, path: string, description = "", searchTerms = "", shortLabel = "") => ({
|
||||
label,
|
||||
path,
|
||||
description,
|
||||
searchTerms,
|
||||
shortLabel,
|
||||
workflow: "System",
|
||||
});
|
||||
|
||||
describe("command menu result ranking", () => {
|
||||
it("filters the complete collection before applying the result limit", () => {
|
||||
const results = Array.from({ length: 20 }, (_, index) => result(`noise-${index}`, `Ander resultaat ${index}`));
|
||||
results.push(result("target", "Release bewijs"));
|
||||
|
||||
expect(rankCommandResults(results, "release bewijs", 12).map((item) => item.id)).toEqual(["target"]);
|
||||
});
|
||||
|
||||
it("ranks an exact label before matches in descriptions", () => {
|
||||
const results = [
|
||||
result("description", "Historiek", "Release bewijs terugvinden"),
|
||||
result("exact", "Release bewijs"),
|
||||
];
|
||||
|
||||
expect(rankCommandResults(results, "release bewijs", 12).map((item) => item.id))
|
||||
.toEqual(["exact", "description"]);
|
||||
});
|
||||
|
||||
it("removes duplicates and honors keys already shown in another group", () => {
|
||||
const duplicate = result("duplicate", "Release bewijs");
|
||||
const other = result("other", "Release bewijs");
|
||||
other.route = duplicate.route;
|
||||
const excluded = new Set([getCommandResultKey(duplicate)]);
|
||||
|
||||
expect(rankCommandResults([duplicate, duplicate, other], "release", 12, excluded)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks navigation by label relevance instead of alphabetical order", () => {
|
||||
const items = [
|
||||
navigationItem("Architectuur", "/architecture", "Technische repositorystructuur", "repo"),
|
||||
navigationItem("Repository", "/develop", "Bestanden, branches en pull requests", "repo"),
|
||||
navigationItem("Repositoryanalyses", "/analysis", "Analysejobs en resultaten", "repo scan"),
|
||||
];
|
||||
|
||||
expect(rankNavigationItems(items, "repository", 8).map((item) => item.label))
|
||||
.toEqual(["Repository", "Repositoryanalyses", "Architectuur"]);
|
||||
});
|
||||
|
||||
it("matches navigation aliases and diacritics consistently", () => {
|
||||
const items = [
|
||||
navigationItem("Extra bronnen", "/sources", "Aanvullend materiaal", "Source Library bronmateriaal"),
|
||||
navigationItem("Uitvoeringsprofielen", "/skills", "Technische profielen", "skills Codex", "Profielen"),
|
||||
];
|
||||
|
||||
expect(rankNavigationItems(items, "bronmateriaal", 8).map((item) => item.path)).toEqual(["/sources"]);
|
||||
expect(rankNavigationItems(items, "profielen", 8).map((item) => item.path)).toEqual(["/skills"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { SearchResult } from "@/shared/lib/projectSearch";
|
||||
|
||||
type CommandSearchResult = Pick<SearchResult, "description" | "id" | "label" | "projectId" | "route" | "type">;
|
||||
type NavigationSearchItem = {
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
shortLabel?: string;
|
||||
searchTerms?: string;
|
||||
workflow?: string;
|
||||
};
|
||||
|
||||
const normalizeSearchText = (value: string) => value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("nl-BE")
|
||||
.trim();
|
||||
|
||||
export const getCommandResultKey = (result: CommandSearchResult) => (
|
||||
`${result.route}|${normalizeSearchText(result.label)}`
|
||||
);
|
||||
|
||||
const getResultScore = (result: CommandSearchResult, normalizedQuery: string, terms: string[]) => {
|
||||
const label = normalizeSearchText(result.label);
|
||||
const type = normalizeSearchText(result.type);
|
||||
const projectId = normalizeSearchText(result.projectId);
|
||||
const description = normalizeSearchText(result.description);
|
||||
const route = normalizeSearchText(result.route);
|
||||
const searchableText = `${label} ${type} ${projectId} ${description} ${route}`;
|
||||
|
||||
if (!terms.every((term) => searchableText.includes(term))) return undefined;
|
||||
if (label === normalizedQuery) return 0;
|
||||
if (label.startsWith(normalizedQuery)) return 10;
|
||||
if (label.includes(normalizedQuery)) return 20;
|
||||
if (`${type} ${projectId}`.includes(normalizedQuery)) return 30;
|
||||
if (description.includes(normalizedQuery)) return 40;
|
||||
return 50;
|
||||
};
|
||||
|
||||
const getNavigationScore = (item: NavigationSearchItem, normalizedQuery: string, terms: string[]) => {
|
||||
const label = normalizeSearchText(item.label);
|
||||
const shortLabel = normalizeSearchText(item.shortLabel ?? "");
|
||||
const description = normalizeSearchText(item.description);
|
||||
const searchTerms = normalizeSearchText(item.searchTerms ?? "");
|
||||
const workflow = normalizeSearchText(item.workflow ?? "");
|
||||
const path = normalizeSearchText(item.path);
|
||||
const searchableText = `${label} ${shortLabel} ${description} ${searchTerms} ${workflow} ${path}`;
|
||||
|
||||
if (!terms.every((term) => searchableText.includes(term))) return undefined;
|
||||
if (label === normalizedQuery || shortLabel === normalizedQuery) return 0;
|
||||
if (label.startsWith(normalizedQuery)) return 10;
|
||||
if (shortLabel.startsWith(normalizedQuery)) return 12;
|
||||
if (label.includes(normalizedQuery)) return 20;
|
||||
if (shortLabel.includes(normalizedQuery)) return 22;
|
||||
if (searchTerms.includes(normalizedQuery)) return 30;
|
||||
if (description.includes(normalizedQuery)) return 40;
|
||||
if (path.includes(normalizedQuery)) return 45;
|
||||
if (workflow.includes(normalizedQuery)) return 50;
|
||||
return 60;
|
||||
};
|
||||
|
||||
export const rankCommandResults = <T extends CommandSearchResult>(
|
||||
results: T[],
|
||||
query: string,
|
||||
limit: number,
|
||||
excludedKeys: ReadonlySet<string> = new Set(),
|
||||
): T[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
const seen = new Set(excludedKeys);
|
||||
|
||||
return results
|
||||
.map((result, index) => ({
|
||||
index,
|
||||
result,
|
||||
score: normalizedQuery ? getResultScore(result, normalizedQuery, terms) : 0,
|
||||
}))
|
||||
.filter((candidate): candidate is typeof candidate & { score: number } => candidate.score !== undefined)
|
||||
.sort((left, right) => (
|
||||
left.score - right.score
|
||||
|| left.result.label.localeCompare(right.result.label, "nl-BE")
|
||||
|| left.index - right.index
|
||||
))
|
||||
.flatMap(({ result }) => {
|
||||
const key = getCommandResultKey(result);
|
||||
if (seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [result];
|
||||
})
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
export const rankNavigationItems = <T extends NavigationSearchItem>(items: T[], query: string, limit: number): T[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
return items
|
||||
.map((item, index) => ({
|
||||
index,
|
||||
item,
|
||||
score: normalizedQuery ? getNavigationScore(item, normalizedQuery, terms) : 0,
|
||||
}))
|
||||
.filter((candidate): candidate is typeof candidate & { score: number } => candidate.score !== undefined)
|
||||
.sort((left, right) => (
|
||||
left.score - right.score
|
||||
|| left.item.label.localeCompare(right.item.label, "nl-BE")
|
||||
|| left.index - right.index
|
||||
))
|
||||
.flatMap(({ item }) => {
|
||||
if (seenPaths.has(item.path)) return [];
|
||||
seenPaths.add(item.path);
|
||||
return [item];
|
||||
})
|
||||
.slice(0, limit);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export const MadeWithDyad = () => (
|
||||
<footer className="border-t py-4 text-center text-sm text-muted-foreground">
|
||||
Made with <a href="https://dyad.sh" target="_blank" rel="noopener noreferrer" className="font-medium underline">Dyad</a>
|
||||
</footer>
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Brain } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface ConfidenceBadgeProps {
|
||||
confidence: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ConfidenceBadge = ({ confidence, className }: ConfidenceBadgeProps) => {
|
||||
const getColor = (score: number) => {
|
||||
if (score >= 90) return "text-green-600 bg-green-50 border-green-200";
|
||||
if (score >= 70) return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
return "text-amber-600 bg-amber-50 border-amber-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("flex items-center gap-1 px-1.5 py-0.5 font-mono text-[10px]", getColor(confidence), className)}>
|
||||
<Brain size={10} />
|
||||
{confidence}%
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
type EmptyStateAction = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "outline" | "secondary" | "ghost";
|
||||
};
|
||||
|
||||
export const EmptyState = ({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: EmptyStateAction;
|
||||
secondaryAction?: EmptyStateAction;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Card className={cn("border-dashed bg-muted/20", className)}>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-background shadow-sm ring-1 ring-border">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
{(action || secondaryAction) && (
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
{action && (
|
||||
<Button size="sm" variant={action.variant ?? "default"} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<Button size="sm" variant={secondaryAction.variant ?? "outline"} onClick={secondaryAction.onClick}>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FileCode, Layout, Hash, Settings } from "lucide-react";
|
||||
import { CodeEvidence } from "@/shared/lib/model";
|
||||
|
||||
interface EvidenceListProps {
|
||||
items: CodeEvidence[];
|
||||
}
|
||||
|
||||
export const EvidenceList = ({ items }: EvidenceListProps) => {
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "Logic": return <FileCode size={14} className="text-blue-500" />;
|
||||
case "UI": return <Layout size={14} className="text-purple-500" />;
|
||||
case "Hook": return <Hash size={14} className="text-green-500" />;
|
||||
case "Config": return <Settings size={14} className="text-slate-500" />;
|
||||
default: return <FileCode size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Code Evidence</h4>
|
||||
<div className="grid gap-2">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-muted/30 border border-border/50 text-xs">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{getIcon(item.type)}
|
||||
<span className="font-mono truncate">{item.file}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">L{item.lines}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader = ({ title, subtitle, actions }: PageHeaderProps) => {
|
||||
return (
|
||||
<div className="mb-6 flex min-w-0 flex-col justify-between gap-3 md:flex-row md:items-start">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="break-words text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{subtitle && <p className="max-w-3xl break-words text-sm leading-6 text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex w-full min-w-0 flex-wrap items-center gap-2 md:w-auto md:justify-end">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: "Todo" | "In Progress" | "Done";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status, className }: StatusBadgeProps) => {
|
||||
const variants = {
|
||||
Todo: "bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-100",
|
||||
"In Progress": "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-50",
|
||||
Done: "bg-green-50 text-green-700 border-green-200 hover:bg-green-50",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("font-medium", variants[status], className)}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), "min-h-11", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 min-h-11 sm:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:translate-y-px disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:shadow-md hover:shadow-primary/20",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm shadow-slate-900/5 hover:border-primary/30 hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
"absolute left-1",
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
"absolute right-1",
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
range_end: "day-range-end",
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
today: "bg-accent text-accent-foreground",
|
||||
outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === "left" ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CardProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
density?: "comfortable" | "compact";
|
||||
surface?: "card" | "hero" | "section" | "row";
|
||||
};
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(({ className, density = "comfortable", surface = "card", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
data-density={density}
|
||||
data-surface={surface}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-[0_1px_2px_hsl(224_42%_11%/0.04)]",
|
||||
surface === "hero" && "rounded-xl border-primary/20 bg-primary/[0.025] shadow-[0_18px_42px_-38px_hsl(224_55%_18%/0.55)]",
|
||||
surface === "section" && "rounded-xl shadow-none",
|
||||
surface === "row" && "shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-xl font-semibold leading-tight tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
RechartsPrimitive.TooltipContentProps &
|
||||
Omit<React.ComponentProps<"div">, "content"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(item.dataKey ?? index)}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
type CommandDialogProps = React.ComponentProps<typeof Dialog>;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<DialogTitle className="sr-only">Opdrachtenpalet</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Zoek ProjectBrain-pagina's, projectbewijs en workflowacties.
|
||||
</DialogDescription>
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-11 cursor-default select-none items-center rounded-sm px-2 py-2 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md bg-background/90 opacity-80 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Sluiten</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,30 @@
|
||||
export * from "./button";
|
||||
export * from "./card";
|
||||
export * from "./badge";
|
||||
export * from "./input";
|
||||
export * from "./label";
|
||||
export * from "./textarea";
|
||||
export * from "./select";
|
||||
export * from "./checkbox";
|
||||
export * from "./dialog";
|
||||
export * from "./dropdown-menu";
|
||||
export * from "./popover";
|
||||
export * from "./tooltip";
|
||||
export * from "./switch";
|
||||
export * from "./separator";
|
||||
export * from "./command";
|
||||
export * from "./sheet";
|
||||
export * from "./scroll-area";
|
||||
export * from "./tabs";
|
||||
export * from "./table";
|
||||
export * from "./avatar";
|
||||
export * from "./skeleton";
|
||||
export * from "./progress";
|
||||
export { Toaster as SonnerToaster } from "./sonner";
|
||||
export { Toaster } from "./toaster";
|
||||
export * from "./use-toast";
|
||||
export * from "./PageHeader";
|
||||
export * from "./StatusBadge";
|
||||
export * from "./ConfidenceBadge";
|
||||
export * from "./EvidenceList";
|
||||
export * from "./EmptyState";
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Dot } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,234 @@
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const normalizedValue = Math.min(100, Math.max(0, value ?? 0));
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
value={normalizedValue}
|
||||
aria-label={props["aria-label"] ?? (props["aria-labelledby"] ? undefined : "Voortgang")}
|
||||
aria-valuetext={props["aria-valuetext"] ?? `${Math.round(normalizedValue)}%`}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
});
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
aria-label={props["aria-label"] ?? (props["aria-labelledby"] ? undefined : "Selecteer een optie")}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-11 cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-11 cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-11 w-full cursor-default select-none items-center rounded-sm py-2 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md bg-background/90 opacity-80 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Sluiten</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
@@ -0,0 +1,769 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden md:block text-sidebar-foreground"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />);
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 max-w-full items-center justify-start overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex min-h-10 shrink-0 items-center justify-center whitespace-nowrap rounded-sm px-3 py-2 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useToast, toast } from "@/hooks/use-toast";
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProjectProvider, useProject } from "@/shared/contexts/ProjectContext";
|
||||
export type { SearchResult } from "@/shared/contexts/ProjectContext";
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Boxes, BrainCircuit, Server, ShieldAlert } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getArchitectureModelsForProject } from "@/shared/lib/projectbrainSelectors";
|
||||
|
||||
const ArchitectureMap = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const architecture = projectId ? getArchitectureModelsForProject(projectId) : undefined;
|
||||
|
||||
if (!architecture) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Architecture Map" subtitle="Architecture-first discovery output for the selected project." />
|
||||
<Card>
|
||||
<CardHeader><CardTitle>No architecture model yet</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>Run an architecture-first check to seed frontend, backend, services, API, deployment and data layers.</p>
|
||||
<Button size="sm" onClick={() => navigate(`/projects/${projectId}/brainstorm?scope=architecture&mode=structure&title=${encodeURIComponent("Architecture discovery")}&prompt=${encodeURIComponent("Explore the missing architecture model for this project before committing to implementation.")}`)}>Brainstorm architecture</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Architecture Map"
|
||||
subtitle="Architecture-first project model used before feature detection and context-pack generation."
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Boxes className="h-5 w-5" /> {architecture.summary}</CardTitle>
|
||||
<CardDescription>Discovery run {architecture.discoveryRunId} - last updated {architecture.lastUpdated}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{architecture.stack.map((tech) => <Badge key={tech} variant="secondary">{tech}</Badge>)}
|
||||
</div>
|
||||
{architecture.risks.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold"><ShieldAlert className="h-4 w-4" /> Architecture risks</div>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{architecture.risks.map((risk) => <li key={risk}>{risk}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{architecture.layers.map((layer) => (
|
||||
<Card key={layer.name}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 capitalize"><Server className="h-4 w-4 text-primary" /> {layer.name}</CardTitle>
|
||||
<CardDescription>{layer.technologies.join(" / ")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{layer.artifacts.map((artifact) => (
|
||||
<div key={artifact.id} className="rounded-md border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{artifact.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{artifact.role}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{artifact.confidence}%</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Source: {artifact.source} - observed {artifact.lastObservedAt}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="border-l-4 border-l-fuchsia-500 bg-fuchsia-500/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><BrainCircuit className="h-5 w-5" /> Architecture Brainstorm CPU</CardTitle>
|
||||
<CardDescription>Stress-test the architecture model before turning recommendations into accepted truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => navigate(`/projects/${projectId}/brainstorm?scope=architecture&mode=stress-test&title=${encodeURIComponent(architecture.summary)}&prompt=${encodeURIComponent([...architecture.stack, ...architecture.risks, ...architecture.recommendations].join("\n"))}`)}>Stress-test architecture</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/brainstorm?scope=architecture&mode=implementation&title=${encodeURIComponent("Architecture implementation slice")}&prompt=${encodeURIComponent(architecture.recommendations[0] ?? architecture.summary)}`)}>Brainstorm implementation slice</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recommended next architecture actions</CardTitle>
|
||||
<CardDescription>Architecture suggestions are review-gated before becoming project truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{architecture.recommendations.map((recommendation) => (
|
||||
<Badge key={recommendation} variant="outline" className="px-3 py-2 font-normal">{recommendation}</Badge>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchitectureMap;
|
||||
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Activity, CheckCircle2, Filter, Gauge, GitBranch, ListChecks, MessageCircleWarning, MessageSquareText, PackageCheck, Search, ShieldAlert, ShieldCheck, Terminal, TerminalSquare, TriangleAlert, Workflow } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { getProjectAuditSummary, projectAuditFindings, type ProjectAuditFinding, type ProjectAuditSeverity, type ProjectAuditStatus } from "@/shared/lib/projectAudit";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { getActiveProjectSkills, getSkillCoverageSummary } from "@/shared/lib/codexSkills";
|
||||
import { getBuildLoopStatus } from "@/shared/lib/buildLoop";
|
||||
import { getBuildLoopModuleExplanation } from "@/shared/lib/buildLoopWalkthrough";
|
||||
import { useProjectReleaseEvidenceData } from "@/shared/hooks/useProjectData";
|
||||
import { buildReleaseEvidenceHygieneSummary } from "@/shared/lib/releaseEvidenceHygiene";
|
||||
|
||||
const severityVariant = (severity: ProjectAuditSeverity): "default" | "secondary" | "destructive" | "outline" => {
|
||||
if (severity === "P0" || severity === "P1") return "destructive";
|
||||
if (severity === "P2") return "default";
|
||||
if (severity === "P3") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const statusVariant = (status: ProjectAuditStatus): "default" | "secondary" | "destructive" | "outline" =>
|
||||
status === "implemented_in_v59" ? "default" : "secondary";
|
||||
|
||||
const statusLabel = (status: ProjectAuditStatus) =>
|
||||
status === "implemented_in_v57" ? "implemented in V57" : status === "implemented_in_v58" ? "implemented in V58" : "implemented in V59";
|
||||
|
||||
const FindingCard = ({ finding }: { finding: ProjectAuditFinding }) => (
|
||||
<Card className="border-primary/30">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{finding.id} · {finding.title}</CardTitle>
|
||||
<CardDescription className="mt-1">Rank #{finding.rank} · impact {finding.impactScore}/200 · effort {finding.effort}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Badge variant="outline">was {finding.severity}</Badge>
|
||||
<Badge variant="outline">{finding.area}</Badge>
|
||||
<Badge variant={statusVariant(finding.status)}>{statusLabel(finding.status)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">Problem</p>
|
||||
<p>{finding.problem}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">Improvement</p>
|
||||
<p>{finding.improvement}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">Acceptance criteria</p>
|
||||
{finding.acceptanceCriteria.map((criteria) => (
|
||||
<div key={criteria} className="flex gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 size={13} className="mt-0.5 text-primary" />
|
||||
<span>{criteria}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const ProjectAuditCenter = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const { findProjectById } = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
const releaseEvidenceResource = useProjectReleaseEvidenceData(projectId);
|
||||
const releaseEvidenceRecords = releaseEvidenceResource.data ?? [];
|
||||
const releaseEvidenceHygiene = buildReleaseEvidenceHygieneSummary(releaseEvidenceRecords);
|
||||
const verifiedReleaseEvidence = releaseEvidenceRecords.filter((record) => record.verificationState === "verified").length;
|
||||
const releaseEvidenceKinds = new Set(releaseEvidenceRecords.map((record) => record.kind)).size;
|
||||
const summary = getProjectAuditSummary();
|
||||
const activeSkills = project ? getActiveProjectSkills(project) : [];
|
||||
const skillCoverage = project ? getSkillCoverageSummary(project, activeSkills) : undefined;
|
||||
const activeRepositoryIntakes = (project?.repositoryIntakes ?? []).filter((intake) => intake.status !== "archived");
|
||||
const repositoryIntakeGapCount = activeRepositoryIntakes.reduce((count, intake) => count + intake.gaps.length, 0);
|
||||
const activeHandoffs = (project?.codexHandoffs ?? []).filter((handoff) => !handoff.archived);
|
||||
const activeHandoffReviews = (project?.handoffReviews ?? []).filter((review) => !review.archived);
|
||||
const handoffAverageScore = activeHandoffs.length ? Math.round(activeHandoffs.reduce((sum, handoff) => sum + handoff.qualityScore.score, 0) / activeHandoffs.length) : 0;
|
||||
const handoffsWithoutValidation = activeHandoffs.filter((handoff) => handoff.validationMatrix.length === 0).length;
|
||||
const handoffsWithoutRollback = activeHandoffs.filter((handoff) => handoff.rollbackPlan.length === 0).length;
|
||||
const handoffsWithWideScope = activeHandoffs.filter((handoff) => handoff.allowedFiles.length > 18 || handoff.warnings.some((warning) => /broad/i.test(warning))).length;
|
||||
const handoffsWithoutReview = activeHandoffs.filter((handoff) => !activeHandoffReviews.some((review) => review.handoffId === handoff.id)).length;
|
||||
const highRiskReviews = activeHandoffReviews.filter((review) => review.riskSummary.highestRisk === "high").length;
|
||||
const approvedReviews = activeHandoffReviews.filter((review) => review.decision === "approved").length;
|
||||
const averageReviewValidation = activeHandoffReviews.length ? Math.round(activeHandoffReviews.reduce((sum, review) => sum + review.validationCoverage.coverageScore, 0) / activeHandoffReviews.length) : 0;
|
||||
const reviewsMissingRollback = activeHandoffReviews.filter((review) => review.rollbackCoverage.coverageScore < 70).length;
|
||||
const activeSessionPacks = (project?.codexSessionPacks ?? []).filter((pack) => !pack.archived);
|
||||
const sessionPackAverageScore = activeSessionPacks.length ? Math.round(activeSessionPacks.reduce((sum, pack) => sum + pack.qualityScore.score, 0) / activeSessionPacks.length) : 0;
|
||||
const approvedHandoffsWithoutSessionPack = activeHandoffs.filter((handoff) => {
|
||||
const review = activeHandoffReviews.find((item) => item.handoffId === handoff.id);
|
||||
return review?.decision === "approved" && !activeSessionPacks.some((pack) => pack.linkedHandoffId === handoff.id);
|
||||
}).length;
|
||||
const sessionPacksWithoutApprovedReview = activeSessionPacks.filter((pack) => {
|
||||
const review = activeHandoffReviews.find((item) => item.id === pack.linkedHandoffReviewId);
|
||||
return review?.decision !== "approved";
|
||||
}).length;
|
||||
const sessionPacksWithoutValidationPlan = activeSessionPacks.filter((pack) => pack.validationPlan.requiredCommands.length === 0).length;
|
||||
const sessionPacksWithoutFeedbackTemplate = activeSessionPacks.filter((pack) => !pack.feedbackTemplate.finalReportTemplate.trim()).length;
|
||||
const sessionPacksWithoutAllowedFiles = activeSessionPacks.filter((pack) => pack.allowedFiles.length === 0).length;
|
||||
const sessionPacksWithoutRollbackPlan = activeSessionPacks.filter((pack) => pack.rollbackPlan.length === 0).length;
|
||||
const sessionPacksWithOperatorPreflightIssues =
|
||||
sessionPacksWithoutApprovedReview +
|
||||
sessionPacksWithoutValidationPlan +
|
||||
sessionPacksWithoutFeedbackTemplate +
|
||||
sessionPacksWithoutAllowedFiles +
|
||||
sessionPacksWithoutRollbackPlan;
|
||||
const sessionPackWarnings = activeSessionPacks.reduce((count, pack) => count + pack.warnings.length, 0);
|
||||
const activeExecutionFeedback = (project?.codexExecutionFeedback ?? []).filter((feedback) => !feedback.archived);
|
||||
const activeBrowserBridgeSessions = (project?.browserBridgeSessions ?? []).filter((session) => !session.archived);
|
||||
const browserBridgeWithoutFeedback = activeBrowserBridgeSessions.filter((session) => !session.linkedFeedbackId).length;
|
||||
const browserBridgeResponsesAwaitingFeedback = activeBrowserBridgeSessions.filter((session) => session.responseText?.trim() && !session.linkedFeedbackId).length;
|
||||
const browserBridgeFeedbackAwaitingReview = activeExecutionFeedback.filter((feedback) =>
|
||||
(project?.browserBridgeSessions ?? []).some((session) => session.linkedFeedbackId === feedback.id) &&
|
||||
feedback.evidenceStatus !== "accepted",
|
||||
).length;
|
||||
const handoffsWithoutFeedback = activeHandoffs.filter((handoff) => !activeExecutionFeedback.some((feedback) => feedback.linkedHandoffId === handoff.id)).length;
|
||||
const feedbackAwaitingReview = activeExecutionFeedback.filter((feedback) => feedback.evidenceStatus === "draft" || feedback.evidenceStatus === "needs-review").length;
|
||||
const failedValidationResults = activeExecutionFeedback.reduce((count, feedback) => count + feedback.validationResults.filter((item) => item.status === "failed" || item.status === "not-run").length, 0);
|
||||
const changedFilesOutsideScope = activeExecutionFeedback.reduce((count, feedback) => count + feedback.changedFiles.filter((file) => !file.matchesExpectedImpact).length, 0);
|
||||
const unresolvedFeedbackFollowUps = activeExecutionFeedback.reduce((count, feedback) => count + feedback.followUps.length + feedback.unresolvedIssues.length, 0);
|
||||
const acceptedFeedback = activeExecutionFeedback.filter((feedback) => feedback.evidenceStatus === "accepted").length;
|
||||
const buildLoopStatus = project ? getBuildLoopStatus(project) : undefined;
|
||||
const moduleExplanation = getBuildLoopModuleExplanation("audit-center");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [area, setArea] = React.useState("all");
|
||||
const [severity, setSeverity] = React.useState<ProjectAuditSeverity | "all">("all");
|
||||
const [status, setStatus] = React.useState<ProjectAuditStatus | "all">("all");
|
||||
|
||||
const areas = summary.areas;
|
||||
const filtered = projectAuditFindings.filter((finding) => {
|
||||
const queryMatch = !query.trim() || `${finding.id} ${finding.title} ${finding.problem} ${finding.improvement} ${finding.area}`.toLowerCase().includes(query.trim().toLowerCase());
|
||||
const areaMatch = area === "all" || finding.area === area;
|
||||
const severityMatch = severity === "all" || finding.severity === severity;
|
||||
const statusMatch = status === "all" || finding.status === status;
|
||||
return queryMatch && areaMatch && severityMatch && statusMatch;
|
||||
});
|
||||
const implemented = projectAuditFindings;
|
||||
const v59Completed = projectAuditFindings.filter((item) => item.status === "implemented_in_v59");
|
||||
const criticalCompleted = projectAuditFindings.filter((item) => item.severity === "P0" || item.severity === "P1");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Project Audit Center"
|
||||
subtitle="Completed CSI baseline with searchable evidence, validation gates and historical closure details. This page reports implemented work only; new improvements are made in the product, not parked here as a to-do list."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/runtime`)}>
|
||||
<Gauge size={14} className="mr-2" /> Runtime Readiness
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{moduleExplanation.title}</CardTitle>
|
||||
<CardDescription>
|
||||
Use this when: {moduleExplanation.useWhen} Outputs: {moduleExplanation.outputs} Next step: {moduleExplanation.nextStep}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{buildLoopStatus?.risks.length ? (
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{buildLoopStatus.risks.slice(0, 3).map((risk) => (
|
||||
<button key={risk.id} type="button" onClick={() => navigate(risk.route)} className="w-full rounded-lg border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-semibold">{risk.title}</p>
|
||||
<Badge variant={risk.level === "high" ? "destructive" : "outline"}>{risk.level}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{risk.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-primary bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Activity size={18} /> RC baseline completion verdict</CardTitle>
|
||||
<CardDescription>The historical CSI queue is closed. New work should now start from live runtime evidence and fresh findings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Findings mapped</p><p className="text-2xl font-semibold">{summary.total}</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Implemented</p><p className="text-2xl font-semibold">{summary.implemented}</p><p className="text-xs text-muted-foreground">+{summary.implementedInV59} in V59</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Queued</p><p className="text-2xl font-semibold">{summary.queued}</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Completion</p><p className="text-2xl font-semibold">{summary.completionPercent}%</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">P0/P1 remaining</p><p className="text-2xl font-semibold">{summary.p0p1Remaining}</p></div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${summary.completionPercent}%` }} />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3 text-sm">
|
||||
<div className="rounded-md border bg-background p-3"><p className="font-medium">What changed</p><p className="text-muted-foreground">The queue is now closed: {summary.implementedInV59} final queued items moved to implemented and the audit script reports a completed baseline.</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="font-medium">Why it matters</p><p className="text-muted-foreground">The Audit Center is now a completed baseline. Future work can start from new findings instead of rediscovering the old queue.</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="font-medium">Boundary</p><p className="text-muted-foreground">Database smoke and runtime validation still need to run locally, but the Audit Center queue itself is no longer open.</p></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Filter size={18} /> Audit filters</CardTitle>
|
||||
<CardDescription>Search and slice the 200-item CSI map by area, severity and implementation state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<div className="relative md:col-span-2">
|
||||
<Search size={15} className="absolute left-3 top-3 text-muted-foreground" />
|
||||
<Input className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search audit findings..." />
|
||||
</div>
|
||||
<Select value={area} onValueChange={setArea}>
|
||||
<SelectTrigger><SelectValue placeholder="Area" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All areas</SelectItem>
|
||||
{areas.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={severity} onValueChange={(value) => setSeverity(value as ProjectAuditSeverity | "all")}>
|
||||
<SelectTrigger><SelectValue placeholder="Severity" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All severities</SelectItem>
|
||||
{(["P0", "P1", "P2", "P3", "P4"] as ProjectAuditSeverity[]).map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as ProjectAuditStatus | "all")}>
|
||||
<SelectTrigger><SelectValue placeholder="Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="implemented_in_v57">Implemented in V57</SelectItem>
|
||||
<SelectItem value="implemented_in_v58">Implemented in V58</SelectItem>
|
||||
<SelectItem value="implemented_in_v59">Implemented in V59</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="implemented" className="space-y-4">
|
||||
<TabsList className="flex flex-wrap h-auto justify-start">
|
||||
<TabsTrigger value="implemented">Implemented</TabsTrigger>
|
||||
<TabsTrigger value="v58">V58 burndown</TabsTrigger>
|
||||
<TabsTrigger value="v59">V59 completion</TabsTrigger>
|
||||
<TabsTrigger value="all">All filtered</TabsTrigger>
|
||||
<TabsTrigger value="critical">Critical completed</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="implemented" className="space-y-3">
|
||||
{implemented.filter((item) => filtered.includes(item)).map((finding) => <FindingCard key={finding.id} finding={finding} />)}
|
||||
</TabsContent>
|
||||
<TabsContent value="v58" className="space-y-3">
|
||||
{projectAuditFindings.filter((item) => item.status === "implemented_in_v58" && filtered.includes(item)).map((finding) => <FindingCard key={finding.id} finding={finding} />)}
|
||||
</TabsContent>
|
||||
<TabsContent value="v59" className="space-y-3">
|
||||
{projectAuditFindings.filter((item) => item.status === "implemented_in_v59" && filtered.includes(item)).map((finding) => <FindingCard key={finding.id} finding={finding} />)}
|
||||
</TabsContent>
|
||||
<TabsContent value="all" className="space-y-3">
|
||||
{filtered.map((finding) => <FindingCard key={finding.id} finding={finding} />)}
|
||||
</TabsContent>
|
||||
<TabsContent value="critical" className="space-y-3">
|
||||
{criticalCompleted.filter((item) => filtered.includes(item)).map((finding) => <FindingCard key={finding.id} finding={finding} />)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ListChecks size={18} /> Historical closure evidence</CardTitle>
|
||||
<CardDescription>These were formerly high-priority items. They remain here as traceability evidence, not as open critical work.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{v59Completed.slice(0, 10).map((finding) => (
|
||||
<div key={finding.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium">{finding.id}</p>
|
||||
<Badge variant="outline">was {finding.severity}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">{finding.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ShieldAlert size={18} /> Validation gates</CardTitle>
|
||||
<CardDescription>Run these locally after extracting the current build.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<code className="block rounded bg-muted p-2">pnpm run typecheck</code>
|
||||
<code className="block rounded bg-muted p-2">pnpm run audit:csi</code>
|
||||
<code className="block rounded bg-muted p-2">pnpm run audit:visual</code>
|
||||
<code className="block rounded bg-muted p-2">pnpm run audit:all</code>
|
||||
<code className="block rounded bg-muted p-2">pnpm run smoke:backend:runtime-suite</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Release proof records</CardTitle>
|
||||
<CardDescription>Persisted release evidence from Runtime Readiness and operator smoke runs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Records</p><p className="text-xl font-semibold">{releaseEvidenceRecords.length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Verified</p><p className="text-xl font-semibold">{verifiedReleaseEvidence}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Smoke proof</p><p className="text-xl font-semibold">{releaseEvidenceHygiene.smokeCount}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Operator proof</p><p className="text-xl font-semibold">{releaseEvidenceHygiene.operatorCount}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Kinds</p><p className="text-xl font-semibold">{releaseEvidenceKinds}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">API</p><p className="text-xl font-semibold">{releaseEvidenceResource.error ? "warn" : "ok"}</p></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{releaseEvidenceRecords.length
|
||||
? "Release proof is no longer only a checklist; it is stored as project-owned evidence and appears in Project History."
|
||||
: "No persisted release proof is visible yet. Capture release evidence from Runtime Readiness after deploy and browser smoke."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/history`)} disabled={!projectId}>
|
||||
Open proof timeline
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/history?proofFilter=release-proof&releaseEvidenceScope=smoke`)} disabled={!projectId}>
|
||||
Review smoke proof
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/runtime`)} disabled={!projectId}>
|
||||
Open Runtime proof
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Terminal size={18} /> Skill coverage</CardTitle>
|
||||
<CardDescription>Current project skills now inform repo readiness, execution handoffs and generated prompts.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Active skills</p><p className="text-xl font-semibold">{skillCoverage?.activeCount ?? 0}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Validation commands</p><p className="text-xl font-semibold">{skillCoverage?.validationCommandCount ?? 0}</p></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{skillCoverage?.summary ?? "Open a project to inspect Codex skill coverage."}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/skills`)} disabled={!projectId}>
|
||||
Open Codex Skills
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><GitBranch size={18} /> Repository intake coverage</CardTitle>
|
||||
<CardDescription>Imported repository evidence is tracked separately from live filesystem truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Active intakes</p><p className="text-xl font-semibold">{activeRepositoryIntakes.length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Repo gaps</p><p className="text-xl font-semibold">{repositoryIntakeGapCount}</p></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{activeRepositoryIntakes.length
|
||||
? "Repo Builder can derive snapshots and gap packages from imported evidence, but those facts still need review before becoming project truth."
|
||||
: "No existing repository intake has been captured for this project yet."}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/repo-builder#repository-intake`)} disabled={!projectId}>
|
||||
Open repository intake
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><TerminalSquare size={18} /> Codex handoff quality</CardTitle>
|
||||
<CardDescription>Controlled build prompts should include scope, validation, rollback and report format.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Handoffs</p><p className="text-xl font-semibold">{activeHandoffs.length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Avg score</p><p className="text-xl font-semibold">{handoffAverageScore}%</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">No validation</p><p className="text-xl font-semibold">{handoffsWithoutValidation}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">No rollback / broad</p><p className="text-xl font-semibold">{handoffsWithoutRollback + handoffsWithWideScope}</p></div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/handoffs`)} disabled={!projectId}>
|
||||
Open Codex Handoffs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ListChecks size={18} /> Handoff review simulation</CardTitle>
|
||||
<CardDescription>Impact reviews are simulated from ProjectBrain context, not live git diffs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">No review</p><p className="text-xl font-semibold">{handoffsWithoutReview}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">High risk</p><p className="text-xl font-semibold">{highRiskReviews}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Approved</p><p className="text-xl font-semibold">{approvedReviews}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Validation avg</p><p className="text-xl font-semibold">{averageReviewValidation}%</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Missing validation</p><p className="text-xl font-semibold">{activeHandoffReviews.filter((review) => review.validationCoverage.coverageScore < 70).length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Missing rollback</p><p className="text-xl font-semibold">{reviewsMissingRollback}</p></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><PackageCheck size={18} /> Codex session pack readiness</CardTitle>
|
||||
<CardDescription>Session packs are the runnable export boundary between approved handoff review and Codex execution.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Session packs</p><p className="text-xl font-semibold">{activeSessionPacks.length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Avg score</p><p className="text-xl font-semibold">{sessionPackAverageScore}%</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Operator preflight issues</p><p className="text-xl font-semibold">{sessionPacksWithOperatorPreflightIssues}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Approved handoffs without pack</p><p className="text-xl font-semibold">{approvedHandoffsWithoutSessionPack}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Pack without approved review</p><p className="text-xl font-semibold">{sessionPacksWithoutApprovedReview}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Missing validation/template</p><p className="text-xl font-semibold">{sessionPacksWithoutValidationPlan + sessionPacksWithoutFeedbackTemplate}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Missing allowed/rollback</p><p className="text-xl font-semibold">{sessionPacksWithoutAllowedFiles + sessionPacksWithoutRollbackPlan}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Warnings</p><p className="text-xl font-semibold">{sessionPackWarnings}</p></div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/session-packs`)} disabled={!projectId}>
|
||||
Open Session Packs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><MessageCircleWarning size={18} /> Execution feedback intake</CardTitle>
|
||||
<CardDescription>Feedback is user-provided execution evidence, not live git truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">No feedback</p><p className="text-xl font-semibold">{handoffsWithoutFeedback}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Awaiting review</p><p className="text-xl font-semibold">{feedbackAwaitingReview}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Failed/not-run validation</p><p className="text-xl font-semibold">{failedValidationResults}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Outside scope</p><p className="text-xl font-semibold">{changedFilesOutsideScope}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Open follow-ups</p><p className="text-xl font-semibold">{unresolvedFeedbackFollowUps}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Accepted</p><p className="text-xl font-semibold">{acceptedFeedback}</p></div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/execution-feedback`)} disabled={!projectId}>
|
||||
Open Execution Feedback
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><MessageSquareText size={18} /> Browser Bridge control</CardTitle>
|
||||
<CardDescription>No API cost browser sessions must still become review-gated execution feedback.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Active sessions</p><p className="text-xl font-semibold">{activeBrowserBridgeSessions.length}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">No feedback</p><p className="text-xl font-semibold">{browserBridgeWithoutFeedback}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Response awaiting feedback</p><p className="text-xl font-semibold">{browserBridgeResponsesAwaitingFeedback}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Feedback awaiting review</p><p className="text-xl font-semibold">{browserBridgeFeedbackAwaitingReview}</p></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
ProjectBrain only prepares prompts and parses pasted responses. It does not automate login, read browser secrets or accept bridge evidence automatically.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/browser-bridge`)} disabled={!projectId}>
|
||||
Open Browser Bridge
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Workflow size={18} /> Build Loop cockpit</CardTitle>
|
||||
<CardDescription>Central phase, blockers, skill gaps and next action across the repo-build cycle.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Current phase</p><p className="text-xl font-semibold">{buildLoopStatus?.phaseLabel ?? "n/a"}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Blockers/risks</p><p className="text-xl font-semibold">{buildLoopStatus?.risks.length ?? 0}</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Readiness</p><p className="text-xl font-semibold">{buildLoopStatus?.readinessScore ?? 0}%</p></div>
|
||||
<div className="rounded-md border p-3"><p className="text-xs text-muted-foreground">Skill gaps</p><p className="text-xl font-semibold">{buildLoopStatus?.skillCoverage.missingRecommendedSkills.length ?? 0}</p></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{buildLoopStatus?.recommendedAction.reason ?? "Open a project to inspect build-loop status."}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/build-loop`)} disabled={!projectId}>
|
||||
Open Build Loop
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-amber-500/40 bg-amber-500/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><TriangleAlert size={18} /> Honest remaining risk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The historical audit queue is closed. Runtime evidence should now come from live database/backend smoke runs and visible product behaviour.</p>
|
||||
<p>The next truly high-impact technical pass should run against Postgres and create new findings from real runtime evidence.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectAuditCenter;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildEntraStartUrl } from "./authNavigation";
|
||||
|
||||
describe("Entra login navigation", () => {
|
||||
it("preserves a project deep link including query and hash", () => {
|
||||
expect(buildEntraStartUrl("/api/auth/entra/start", {
|
||||
pathname: "/projects/projectbrain/development",
|
||||
search: "?delivery=plan-7",
|
||||
hash: "#editor",
|
||||
})).toBe("/api/auth/entra/start?returnTo=%2Fprojects%2Fprojectbrain%2Fdevelopment%3Fdelivery%3Dplan-7%23editor");
|
||||
});
|
||||
|
||||
it("appends to an existing start query", () => {
|
||||
expect(buildEntraStartUrl("/api/auth/entra/start?prompt=login", { pathname: "/projects", search: "", hash: "" }))
|
||||
.toBe("/api/auth/entra/start?prompt=login&returnTo=%2Fprojects");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const buildEntraStartUrl = (startPath: string, location: Pick<Location, "pathname" | "search" | "hash">) => {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
const separator = startPath.includes("?") ? "&" : "?";
|
||||
return `${startPath}${separator}returnTo=${encodeURIComponent(returnTo)}`;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { BrainCircuit, Building2, Loader2, LockKeyhole, RefreshCw, WifiOff } from "lucide-react";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { httpClient } from "@/shared/api/httpClient";
|
||||
import { apiPaths, type EntraPublicConfig } from "@projectbrain/contracts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { buildEntraStartUrl } from "../lib/authNavigation";
|
||||
|
||||
export const Login = () => {
|
||||
const { login, loading, error, refresh } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [entraState, setEntraState] = useState<"loading" | "enabled" | "disabled" | "unavailable">("loading");
|
||||
const entraError = new URLSearchParams(window.location.search).get("entraError");
|
||||
const entraErrorMessage: Record<string, string> = {
|
||||
cancelled: "Aanmelden met Microsoft werd geannuleerd.",
|
||||
expired_attempt: "De Microsoft-aanmeldpoging is verlopen. Probeer opnieuw.",
|
||||
not_linked: "Dit Microsoft-account is nog niet gekoppeld. Meld één keer aan met het lokale owner-account en koppel Entra onder Settings.",
|
||||
invalid_callback: "Microsoft stuurde geen geldige aanmeldrespons terug.",
|
||||
authentication_failed: "Microsoft Entra kon niet veilig worden gevalideerd. Probeer opnieuw of gebruik de herstel-login.",
|
||||
};
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void httpClient.get<EntraPublicConfig>(apiPaths.auth.entraConfig)
|
||||
.then((response) => { if (active) setEntraState(response.data.enabled ? "enabled" : "disabled"); })
|
||||
.catch(() => { if (active) setEntraState("unavailable"); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
try { await login(username.trim(), password); } catch { /* Error is rendered by the provider. */ }
|
||||
};
|
||||
const startEntraLogin = () => {
|
||||
window.location.assign(buildEntraStartUrl(apiPaths.auth.entraStart, window.location));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<BrainCircuit className="mx-auto h-10 w-10 text-primary" aria-hidden="true" />
|
||||
<CardTitle className="text-2xl">Aanmelden bij ProjectBrain</CardTitle>
|
||||
<CardDescription>Meld aan met Microsoft Entra ID of gebruik het lokale owner-account als herstelroute.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entraState === "enabled" || entraState === "unavailable" ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="w-full" onClick={startEntraLogin}>
|
||||
<Building2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Aanmelden met Microsoft
|
||||
</Button>
|
||||
{entraState === "unavailable" ? <p className="mt-2 flex items-start gap-2 text-xs text-amber-700"><WifiOff className="mt-0.5 h-3.5 w-3.5 shrink-0" />De Microsoft-status kon niet worden gecontroleerd. Je kunt de koppeling toch opnieuw proberen.</p> : null}
|
||||
<div className="my-5 flex items-center gap-3" aria-hidden="true"><span className="h-px flex-1 bg-border" /><span className="text-xs uppercase tracking-wide text-muted-foreground">of herstel-login</span><span className="h-px flex-1 bg-border" /></div>
|
||||
</>
|
||||
) : null}
|
||||
{entraError ? <p role="alert" className="mb-4 text-sm text-destructive">{entraErrorMessage[entraError] ?? "Aanmelden met Microsoft is mislukt."}</p> : null}
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectbrain-username">Gebruikersnaam</Label>
|
||||
<Input id="projectbrain-username" name="username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required minLength={3} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectbrain-password">Wachtwoord</Label>
|
||||
<Input id="projectbrain-password" name="password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required minLength={12} />
|
||||
</div>
|
||||
{error ? <div role="alert" className="space-y-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900"><p>{error}</p><Button type="button" size="sm" variant="outline" onClick={() => void refresh()}><RefreshCw className="mr-2 h-3.5 w-3.5" />Sessie opnieuw controleren</Button></div> : null}
|
||||
<Button type="submit" className="w-full" disabled={loading || !username.trim() || password.length < 12}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <LockKeyhole className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
Aanmelden
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui";
|
||||
import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi";
|
||||
import { ApiError } from "@/shared/api/httpClient";
|
||||
import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation";
|
||||
import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
|
||||
const phaseVariant = (phase: AutoModePhase) => phase.status === "completed" ? "default" : phase.status === "blocked" ? "destructive" : phase.status === "pending" ? "secondary" : "outline";
|
||||
const readableError = (error: unknown) => error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Onbekende fout.";
|
||||
|
||||
const AutoMode = () => {
|
||||
const { projectId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [state, setState] = React.useState<AutoModeState>();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
const [roadmapTitle, setRoadmapTitle] = React.useState("");
|
||||
const [roadmapMarkdown, setRoadmapMarkdown] = React.useState("");
|
||||
const [confirmRoadmapReplacement, setConfirmRoadmapReplacement] = React.useState(false);
|
||||
const loadGeneration = React.useRef(0);
|
||||
const [policy, setPolicy] = React.useState({ profile: "normal" as AutoModeState["profile"], autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45, maxRecoveryMinutes: 120 });
|
||||
|
||||
const load = React.useCallback(async (preservePolicy = false) => {
|
||||
if (!projectId) return;
|
||||
const generation = ++loadGeneration.current;
|
||||
setLoading(true); setError(undefined);
|
||||
try {
|
||||
const next = await autonomyApi.get(projectId);
|
||||
if (generation !== loadGeneration.current) return;
|
||||
setState(next);
|
||||
if (!preservePolicy) setPolicy({ profile: next.profile ?? "normal", autoBrainstorm: next.autoBrainstorm, maxBrainstormIterations: next.maxBrainstormIterations, maxConsecutiveFailures: next.maxConsecutiveFailures, leaseMinutes: next.leaseMinutes, maxWorkItemsPerRun: next.maxWorkItemsPerRun, maxRunMinutes: next.maxRunMinutes, maxRecoveryMinutes: next.maxRecoveryMinutes });
|
||||
if (next.roadmap?.sourceMarkdown) { setRoadmapTitle((current) => current || next.roadmap?.title || ""); setRoadmapMarkdown((current) => current || next.roadmap?.sourceMarkdown || ""); }
|
||||
} catch (nextError) { if (generation === loadGeneration.current) setError(readableError(nextError)); }
|
||||
finally { if (generation === loadGeneration.current) setLoading(false); }
|
||||
}, [projectId]);
|
||||
React.useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
|
||||
|
||||
const configure = async (patch: Parameters<typeof autonomyApi.configure>[1]) => {
|
||||
if (!projectId) return false;
|
||||
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
|
||||
try {
|
||||
const next = await autonomyApi.configure(projectId, patch); setState(next);
|
||||
if (patch.enabled === undefined) setPolicy({ profile: next.profile ?? "normal", autoBrainstorm: next.autoBrainstorm, maxBrainstormIterations: next.maxBrainstormIterations, maxConsecutiveFailures: next.maxConsecutiveFailures, leaseMinutes: next.leaseMinutes, maxWorkItemsPerRun: next.maxWorkItemsPerRun, maxRunMinutes: next.maxRunMinutes, maxRecoveryMinutes: next.maxRecoveryMinutes });
|
||||
return true;
|
||||
} catch (nextError) { setError(readableError(nextError)); return false; }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const importRoadmap = async () => {
|
||||
if (!projectId || roadmapMarkdown.trim().length < 3) return;
|
||||
if (state?.roadmap && state.roadmap.sourceMarkdown.trim() === roadmapMarkdown.trim() && state.roadmap.title === roadmapTitle.trim()) { showSuccess("Deze roadmap is al opgeslagen. De voortgang blijft behouden."); return; }
|
||||
const replacesExistingRoadmap = Boolean(state?.roadmap && state.roadmap.sourceMarkdown.trim() !== roadmapMarkdown.trim());
|
||||
if (replacesExistingRoadmap && !confirmRoadmapReplacement) { setConfirmRoadmapReplacement(true); return; }
|
||||
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
|
||||
try { setState(await autonomyApi.importRoadmap(projectId, { ...(roadmapTitle.trim() ? { title: roadmapTitle.trim() } : {}), markdown: roadmapMarkdown })); setConfirmRoadmapReplacement(false); showSuccess("Roadmap opgeslagen"); }
|
||||
catch (nextError) { setError(readableError(nextError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const resume = async () => {
|
||||
if (!projectId) return;
|
||||
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
|
||||
try { setState(await autonomyApi.resume(projectId)); showSuccess("Auto Mode kan opnieuw werk claimen"); }
|
||||
catch (nextError) { setError(readableError(nextError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); };
|
||||
|
||||
const phases = state?.roadmap?.phases ?? [];
|
||||
const items = phases.flatMap((phase) => phase.items);
|
||||
const completedItems = items.filter((item) => item.status === "completed").length;
|
||||
const openItems = items.filter((item) => item.status !== "completed");
|
||||
const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0;
|
||||
const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined;
|
||||
const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress");
|
||||
const activeItem = inProgressItem ?? activePhase?.items.find((item) => item.status !== "completed");
|
||||
const nextItem = phases.flatMap((phase) => phase.items).find((item) => item.status === "pending");
|
||||
const operational = state ? getAutoModeOperationalState(state) : undefined;
|
||||
const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes));
|
||||
const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); };
|
||||
const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); };
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !policyDirty) void load(true); }, 10_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [load, policyDirty, projectId, saving]);
|
||||
|
||||
if (loading && !state) return <div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">Auto Mode laden…</div>;
|
||||
const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed";
|
||||
|
||||
return <div className="w-full min-w-0 max-w-full space-y-6 [overflow-wrap:anywhere] [&_button]:max-w-full max-sm:[&_button]:whitespace-normal">
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-3xl font-bold tracking-tight">Auto Mode</h1>{state ? <Badge variant={state.status === "blocked" && state.blocker?.kind === "human" ? "destructive" : state.enabled ? "default" : "secondary"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModeStatusLabel[state.status]}</Badge> : null}</div><p className="mt-2 max-w-3xl text-muted-foreground">ProjectBrain stuurt roadmap, afhankelijkheden en herstel; Gitea is de uitvoerings- en evidencebron voor repositorywerk.</p></div><div className="flex max-w-full flex-wrap gap-2"><Button onClick={() => navigate(`/projects/${projectId}/workspace`)}><ListTodo className="mr-2 h-4 w-4" />Opdracht geven</Button><Button variant="outline" onClick={() => void load(policyDirty)} disabled={loading || saving}><RefreshCw className="mr-2 h-4 w-4" />Vernieuwen</Button></div></div>
|
||||
{error ? <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
|
||||
|
||||
{state && operational ? <Card className={controllerClass}>
|
||||
<CardContent className="space-y-5 p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 gap-3"><span className="shrink-0 rounded-full bg-muted p-2">{operational.tone === "blocked" ? <ShieldAlert className="h-5 w-5 text-destructive" /> : operational.tone === "retry" ? <RefreshCw className="h-5 w-5 text-amber-700" /> : <Bot className="h-5 w-5" />}</span><div className="min-w-0"><div className="flex flex-wrap gap-2"><Badge variant="outline" className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{operational.label}</Badge><Badge variant={state.enabled ? "default" : "outline"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">Auto {state.enabled ? "aan" : "uit"}</Badge></div><h2 className="mt-2 text-xl font-semibold">{operational.headline}</h2><p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{operational.detail}</p></div></div>
|
||||
<div className="flex max-w-full flex-wrap gap-2"><div className="flex items-center gap-3 rounded-lg border px-4 py-3"><Label htmlFor="auto-mode-toggle" className="font-semibold">Auto</Label><Switch id="auto-mode-toggle" checked={state.enabled} disabled={saving} onCheckedChange={(enabled) => void configure({ enabled })} /></div>{state.enabled ? <Button variant="outline" onClick={() => void configure({ pauseAfterCurrent: true })} disabled={saving || state.pauseAfterCurrent}>{state.pauseAfterCurrent ? "Pauze gepland" : "Pauzeer na huidig werk"}</Button> : null}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? activePhase?.title ?? "Volgende veilige stap wordt bepaald" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">Repositorywerk wordt atomair geclaimd via <code className="[overflow-wrap:anywhere]">projectbrain/auto/<work-id></code>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.</p>
|
||||
</CardContent>
|
||||
</Card> : null}
|
||||
|
||||
{state?.blocker?.kind === "retryable" ? <Card className="border-amber-300 bg-amber-50/30"><CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">Automatisch herstel</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.</p>{state.blocker.retryAt ? <p className="mt-1 text-xs text-muted-foreground">Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}</p> : null}</div><Button variant="outline" onClick={() => void resume()} disabled={saving}><RotateCcw className="mr-2 h-4 w-4" />Nu opnieuw proberen</Button></CardContent></Card> : null}
|
||||
{state?.blocker?.kind === "human" ? <Card className="border-destructive/40"><CardContent className="flex min-w-0 flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div className="min-w-0"><p className="font-semibold text-destructive">Jouw beslissing of toegang nodig</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason}</p></div><div className="flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => navigate(`/projects/${projectId}/attention`)}>Jouw aandacht <ArrowRight size={14} /></Button><Button variant="outline" onClick={() => void resume()} disabled={saving}>Na oplossing hervatten</Button></div></CardContent></Card> : null}
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Autonomiebeleid en geavanceerde instellingen</summary><div className="grid min-w-0 gap-6 border-t p-4 xl:grid-cols-2"><Card className="min-w-0"><CardHeader><CardTitle>Autonomieprofiel</CardTitle><CardDescription>Routinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="grid min-w-0 gap-2 sm:grid-cols-2">{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return <Button key={profile} type="button" variant={policy.profile === profile ? "default" : "outline"} className="h-auto min-h-16 w-full min-w-0 max-w-full justify-start whitespace-normal text-left [overflow-wrap:anywhere]" onClick={() => selectProfile(profile)}><span className="min-w-0"><span className="block font-semibold">{definition.label}</span><span className="mt-1 block text-xs font-normal opacity-80">{definition.description}</span></span></Button>; })}</div><details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Geavanceerde grenzen</summary><div className="grid min-w-0 gap-4 border-t p-3 sm:grid-cols-2"><div className="min-w-0 space-y-2"><Label htmlFor="brainstorm-iterations">Max. brainstormrondes</Label><Input id="brainstorm-iterations" type="number" min={1} max={10} value={policy.maxBrainstormIterations} onChange={(event) => updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="failure-limit">Retry-drempel</Label><Input id="failure-limit" type="number" min={1} max={10} value={policy.maxConsecutiveFailures} onChange={(event) => updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="lease-minutes">Compatibiliteitslease (min.)</Label><Input id="lease-minutes" type="number" min={5} max={120} value={policy.leaseMinutes} onChange={(event) => updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-items">Werkitems per run</Label><Input id="run-items" type="number" min={1} max={20} value={policy.maxWorkItemsPerRun} onChange={(event) => updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-minutes">Looptijd per run</Label><Input id="run-minutes" type="number" min={5} max={55} value={policy.maxRunMinutes} onChange={(event) => updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="recovery-minutes">Max. recoveryduur</Label><Input id="recovery-minutes" type="number" min={15} max={1440} value={policy.maxRecoveryMinutes} onChange={(event) => updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} /></div><div className="flex min-w-0 items-center justify-between gap-4 sm:col-span-2"><div className="min-w-0"><Label htmlFor="auto-brainstorm">Convergence-brainstorm</Label><p className="text-xs text-muted-foreground">Zoek na concrete taken nog naar echte gaten.</p></div><Switch id="auto-brainstorm" checked={policy.autoBrainstorm} onCheckedChange={(checked) => setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} /></div></div></details><Button variant="outline" className="w-full" onClick={() => void savePolicy()} disabled={saving || !policyDirty}>Beleid opslaan</Button></CardContent></Card><Card className="min-w-0"><CardHeader><CardTitle>Roadmap handmatig importeren</CardTitle><CardDescription>Alleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-title">Titel</Label><Input id="roadmap-title" value={roadmapTitle} onChange={(event) => setRoadmapTitle(event.target.value)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-markdown">Roadmap</Label><Textarea id="roadmap-markdown" value={roadmapMarkdown} onChange={(event) => { setRoadmapMarkdown(event.target.value); setConfirmRoadmapReplacement(false); }} rows={10} className="max-w-full min-w-0 font-mono text-sm" /></div>{confirmRoadmapReplacement ? <div role="alert" className="min-w-0 rounded-lg border border-amber-300 bg-amber-50/40 p-4"><p className="font-semibold">Bestaande roadmap en voortgang vervangen?</p><div className="mt-3 flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => void importRoadmap()} disabled={saving}>Definitief vervangen</Button><Button variant="outline" onClick={() => setConfirmRoadmapReplaLine truncated
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"><BrainCircuit className="mr-2 inline-block h-5 w-5 align-text-bottom" />Technische autonome activiteit</summary><div className="border-t p-4">{state?.events.length ? <div className="space-y-3">{[...state.events].reverse().slice(0, 20).map((entry) => <div key={entry.id} className="min-w-0 border-l-2 pl-3"><div className="flex min-w-0 flex-wrap items-center gap-2"><span className="min-w-0 text-sm font-medium">{entry.summary}</span><Badge variant="outline" className="max-w-full whitespace-normal text-[10px] [overflow-wrap:anywhere]">{entry.actor}</Badge></div><p className="mt-1 text-xs text-muted-foreground">{new Date(entry.at).toLocaleString()} · {entry.type}</p></div>)}</div> : <p className="text-sm text-muted-foreground">Nog geen autonome activiteit.</p>}</div></details>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default AutoMode;
|
||||
@@ -0,0 +1,917 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Download,
|
||||
FileText,
|
||||
FolderTree,
|
||||
GitBranch,
|
||||
Layers3,
|
||||
ListChecks,
|
||||
Map,
|
||||
Network,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { realClient } from "@/shared/api/realClient";
|
||||
import { useProjectBlueprintPlanData } from "@/shared/hooks/useProjectData";
|
||||
import {
|
||||
createBlueprintImpactReport,
|
||||
createBlueprintIndexMarkdown,
|
||||
createBlueprintNodeRunPreview,
|
||||
createBlueprintPlan,
|
||||
createBlueprintRepoExportBundle,
|
||||
type BlueprintDepth,
|
||||
type BlueprintNode,
|
||||
type BlueprintNodeRun,
|
||||
} from "@/shared/lib/blueprintEngine";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
import { appLogger } from "@/shared/lib/appLogger";
|
||||
|
||||
const depthLabels: Record<BlueprintDepth, string> = {
|
||||
light: "Light",
|
||||
standard: "Standard",
|
||||
deep: "Deep",
|
||||
full: "Full",
|
||||
};
|
||||
|
||||
const statusVariant = (status: BlueprintNode["status"]): "default" | "secondary" | "destructive" | "outline" => {
|
||||
if (status === "ready" || status === "accepted") return "default";
|
||||
if (status === "blocked" || status === "stale") return "destructive";
|
||||
if (status === "needs_review") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const scoreVariant = (score: number): "default" | "secondary" | "destructive" | "outline" => {
|
||||
if (score >= 75) return "default";
|
||||
if (score >= 55) return "secondary";
|
||||
return "destructive";
|
||||
};
|
||||
|
||||
const BlueprintEngine = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const { projects, contextPacks } = useProject();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const [depth, setDepth] = React.useState<BlueprintDepth | "auto">("auto");
|
||||
const [selectedNodeId, setSelectedNodeId] = React.useState<string | undefined>();
|
||||
const [nodeRun, setNodeRun] = React.useState<BlueprintNodeRun | undefined>();
|
||||
const [isRunningNode, setIsRunningNode] = React.useState(false);
|
||||
const [includeDraftsInExport, setIncludeDraftsInExport] = React.useState(true);
|
||||
const [exportBundle, setExportBundle] = React.useState<ReturnType<typeof createBlueprintRepoExportBundle> | undefined>();
|
||||
const [isRegeneratingStale, setIsRegeneratingStale] = React.useState(false);
|
||||
const [isRegeneratingSelected, setIsRegeneratingSelected] = React.useState(false);
|
||||
const [isGeneratingNext, setIsGeneratingNext] = React.useState(false);
|
||||
const [isGeneratingRootBatch, setIsGeneratingRootBatch] = React.useState(false);
|
||||
const [statusFilter, setStatusFilter] = React.useState<BlueprintNode["status"] | "all">("all");
|
||||
const [clusterFilter, setClusterFilter] = React.useState<string>("all");
|
||||
const blueprintResource = useProjectBlueprintPlanData(projectId, depth);
|
||||
|
||||
const projectPacks = React.useMemo(
|
||||
() => contextPacks.filter((pack) => pack.projectId === projectId),
|
||||
[contextPacks, projectId],
|
||||
);
|
||||
|
||||
const localPlan = React.useMemo(() => {
|
||||
if (!project) return undefined;
|
||||
return createBlueprintPlan({
|
||||
project,
|
||||
contextPacks: projectPacks,
|
||||
depth: depth === "auto" ? undefined : depth,
|
||||
});
|
||||
}, [depth, project, projectPacks]);
|
||||
|
||||
const plan = blueprintResource.data ?? localPlan;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedNodeId && plan?.nodes.length) setSelectedNodeId(plan.nodes[0].id);
|
||||
if (selectedNodeId && plan && !plan.nodes.some((node) => node.id === selectedNodeId)) {
|
||||
setSelectedNodeId(plan.nodes[0]?.id);
|
||||
}
|
||||
}, [plan, selectedNodeId]);
|
||||
|
||||
if (!plan) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const selectedNode = plan.nodes.find((node) => node.id === selectedNodeId) ?? plan.nodes[0];
|
||||
const indexMarkdown = createBlueprintIndexMarkdown(plan);
|
||||
const selectedCluster = selectedNode?.cluster;
|
||||
const impactReport = selectedNode ? createBlueprintImpactReport(plan, selectedNode.id) : undefined;
|
||||
const acceptedNodes = plan.nodes.filter((node) => node.status === "accepted" || node.status === "exported").length;
|
||||
const reviewNodes = plan.nodes.filter((node) => node.status === "needs_review").length;
|
||||
const staleNodes = plan.nodes.filter((node) => node.status === "stale").length;
|
||||
const blockedNodes = plan.nodes.filter((node) => node.status === "blocked").length;
|
||||
const exportableNodes = plan.nodes.filter((node) => node.status !== "blocked").length;
|
||||
const progressPercent = plan.nodes.length ? Math.round((acceptedNodes / plan.nodes.length) * 100) : 0;
|
||||
const dependencyCoverage = plan.nodes.length
|
||||
? Math.round((plan.nodes.filter((node) => node.blockedBy.length === 0).length / plan.nodes.length) * 100)
|
||||
: 0;
|
||||
const contextCoverage = plan.nodes.length
|
||||
? Math.round((plan.nodes.filter((node) => Boolean(node.contextSelection.primaryContextPackId)).length / plan.nodes.length) * 100)
|
||||
: 0;
|
||||
const filteredNodes = plan.nodes.filter((node) => {
|
||||
const statusMatch = statusFilter === "all" || node.status === statusFilter;
|
||||
const clusterMatch = clusterFilter === "all" || node.cluster === clusterFilter;
|
||||
return statusMatch && clusterMatch;
|
||||
});
|
||||
const exportRiskLevel = includeDraftsInExport
|
||||
? "Draft-inclusive export: useful for repository scaffolding, but generated drafts are not accepted truth."
|
||||
: staleNodes || reviewNodes
|
||||
? "Accepted-only export: safe by default, but some pending/stale docs will be skipped."
|
||||
: "Accepted-only export: safest export mode.";
|
||||
const nextReadyNode = plan.nodes.find((node) => node.status === "ready") ?? plan.nodes.find((node) => node.status === "pending");
|
||||
const rootRunnableNodes = plan.nodes.filter((node) => node.dependsOn.length === 0 && !["accepted", "exported", "needs_review", "blocked"].includes(node.status));
|
||||
const reviewPath = `/projects/${plan.projectId}/review-queue`;
|
||||
const docsPath = `/projects/${plan.projectId}/documentation`;
|
||||
const contextPath = `/projects/${plan.projectId}/context-packs`;
|
||||
const blueprintStages = [
|
||||
{
|
||||
title: "1. Plan",
|
||||
status: plan.nodes.length ? "ready" : "blocked",
|
||||
summary: `${plan.totalNodes} docs mapped across ${plan.clusters.length} clusters`,
|
||||
action: "Documentation graph exists",
|
||||
},
|
||||
{
|
||||
title: "2. Generate",
|
||||
status: reviewNodes || acceptedNodes ? "ready" : nextReadyNode ? "active" : "blocked",
|
||||
summary: nextReadyNode ? `Next: ${nextReadyNode.title}` : "No ready node found",
|
||||
action: "Generate next ready doc",
|
||||
},
|
||||
{
|
||||
title: "3. Review",
|
||||
status: reviewNodes ? "active" : acceptedNodes ? "ready" : "pending",
|
||||
summary: `${reviewNodes} documents waiting for approval`,
|
||||
action: "Open Review Queue",
|
||||
},
|
||||
{
|
||||
title: "4. Export",
|
||||
status: acceptedNodes ? "active" : "pending",
|
||||
summary: `${acceptedNodes} accepted docs ready for safe export`,
|
||||
action: "Download repo zip",
|
||||
},
|
||||
{
|
||||
title: "5. Maintain",
|
||||
status: staleNodes ? "active" : "ready",
|
||||
summary: staleNodes ? `${staleNodes} stale docs need regeneration` : "No stale docs detected",
|
||||
action: "Regenerate stale docs",
|
||||
},
|
||||
];
|
||||
|
||||
const generateNode = async (node: BlueprintNode, successMessage?: string) => {
|
||||
setSelectedNodeId(node.id);
|
||||
if (projectBrainClientMode === "real") {
|
||||
const response = await realClient.blueprints.runBlueprintNode(plan.projectId, plan.id, node.id, {
|
||||
createDocumentation: true,
|
||||
sendToReview: true,
|
||||
});
|
||||
setNodeRun(response.data);
|
||||
await blueprintResource.reload();
|
||||
showSuccess(successMessage ?? `${node.title} generated and sent to review.`);
|
||||
return response.data;
|
||||
}
|
||||
const preview = createBlueprintNodeRunPreview(plan, node);
|
||||
setNodeRun(preview);
|
||||
showSuccess(successMessage ?? `${node.title} draft preview generated locally.`);
|
||||
return preview;
|
||||
};
|
||||
|
||||
const generateNextReadyNode = async () => {
|
||||
if (!nextReadyNode) {
|
||||
showSuccess("No ready blueprint node found. Review, accept, or regenerate existing nodes first.");
|
||||
return;
|
||||
}
|
||||
setIsGeneratingNext(true);
|
||||
try {
|
||||
await generateNode(nextReadyNode, `Next blueprint document generated: ${nextReadyNode.title}`);
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint next document generation failed", error);
|
||||
showError("Generating the next ready blueprint document failed. Check database mode or use local preview.");
|
||||
} finally {
|
||||
setIsGeneratingNext(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateRootBatch = async () => {
|
||||
if (!rootRunnableNodes.length) {
|
||||
showSuccess("No root blueprint documents are ready for batch generation.");
|
||||
return;
|
||||
}
|
||||
setIsGeneratingRootBatch(true);
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
for (const node of rootRunnableNodes.slice(0, 5)) {
|
||||
await realClient.blueprints.runBlueprintNode(plan.projectId, plan.id, node.id, {
|
||||
createDocumentation: true,
|
||||
sendToReview: true,
|
||||
});
|
||||
}
|
||||
await blueprintResource.reload();
|
||||
showSuccess(`${Math.min(rootRunnableNodes.length, 5)} root blueprint documents generated and sent to review.`);
|
||||
return;
|
||||
}
|
||||
const first = rootRunnableNodes[0];
|
||||
setSelectedNodeId(first.id);
|
||||
setNodeRun(createBlueprintNodeRunPreview(plan, first));
|
||||
showSuccess(`Local batch preview generated for ${first.title}. Database mode persists the full root batch.`);
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint root batch generation failed", error);
|
||||
showError("Root blueprint batch generation failed. Check database mode and try again.");
|
||||
} finally {
|
||||
setIsGeneratingRootBatch(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyExportManifest = async () => {
|
||||
await navigator.clipboard.writeText(indexMarkdown);
|
||||
showSuccess("Blueprint export manifest copied.");
|
||||
};
|
||||
|
||||
const prepareRepoExport = async () => {
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
const response = await realClient.blueprints.exportBlueprint(plan.projectId, plan.id, includeDraftsInExport);
|
||||
setExportBundle(response.data);
|
||||
await navigator.clipboard.writeText(response.data.manifestMarkdown);
|
||||
showSuccess("Blueprint repo export prepared and manifest copied.");
|
||||
return;
|
||||
}
|
||||
const bundle = createBlueprintRepoExportBundle(plan, { includeDrafts: includeDraftsInExport });
|
||||
setExportBundle(bundle);
|
||||
await navigator.clipboard.writeText(bundle.manifestMarkdown);
|
||||
showSuccess("Local blueprint repo export manifest prepared and copied.");
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint repo export failed", error);
|
||||
const fallback = createBlueprintRepoExportBundle(plan, { includeDrafts: includeDraftsInExport });
|
||||
setExportBundle(fallback);
|
||||
await navigator.clipboard.writeText(fallback.manifestMarkdown);
|
||||
showSuccess("Backend export failed; local export manifest copied instead.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const downloadRepoZip = async () => {
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
const blob = await realClient.blueprints.exportBlueprintZip(plan.projectId, plan.id, includeDraftsInExport);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `projectbrain-blueprint-${plan.id}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showSuccess("Blueprint repo zip download started.");
|
||||
return;
|
||||
}
|
||||
const fallback = createBlueprintRepoExportBundle(plan, { includeDrafts: includeDraftsInExport });
|
||||
const blob = new Blob([fallback.manifestMarkdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `projectbrain-blueprint-${plan.id}-export-index.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showSuccess("Local mode cannot create the backend zip; manifest markdown download started instead.");
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint repo zip download failed", error);
|
||||
showError("Blueprint repo zip could not be downloaded; prepare the export manifest instead.");
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateSelectedNode = async () => {
|
||||
if (!selectedNode) return;
|
||||
setIsRegeneratingSelected(true);
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
const response = await realClient.blueprints.regenerateBlueprintNode(plan.projectId, plan.id, selectedNode.id, {
|
||||
createDocumentation: true,
|
||||
sendToReview: true,
|
||||
});
|
||||
setNodeRun(response.data.run);
|
||||
await blueprintResource.reload();
|
||||
showSuccess(response.data.message);
|
||||
return;
|
||||
}
|
||||
setNodeRun(createBlueprintNodeRunPreview(plan, selectedNode));
|
||||
showSuccess("Local node regeneration preview generated. Database mode persists it as a review-gated draft.");
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint node regeneration failed", error);
|
||||
showError("Blueprint node regeneration failed; check database mode and blueprint persistence.");
|
||||
} finally {
|
||||
setIsRegeneratingSelected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateStaleNodes = async () => {
|
||||
const staleCount = plan.nodes.filter((node) => node.status === "stale").length;
|
||||
if (!staleCount) {
|
||||
showSuccess("No stale blueprint nodes need regeneration.");
|
||||
return;
|
||||
}
|
||||
setIsRegeneratingStale(true);
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
const response = await realClient.blueprints.regenerateStaleBlueprintNodes(plan.projectId, plan.id, {
|
||||
limit: 25,
|
||||
createDocumentation: true,
|
||||
sendToReview: true,
|
||||
});
|
||||
await blueprintResource.reload();
|
||||
showSuccess(response.data.message);
|
||||
return;
|
||||
}
|
||||
const staleNode = plan.nodes.find((node) => node.status === "stale") ?? selectedNode;
|
||||
if (staleNode) setNodeRun(createBlueprintNodeRunPreview(plan, staleNode));
|
||||
showSuccess("Local preview generated for the first stale node. Database mode is required to create review-gated regenerated drafts.");
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint stale-node regeneration failed", error);
|
||||
showError("Stale-node regeneration failed; check database mode and blueprint persistence.");
|
||||
} finally {
|
||||
setIsRegeneratingStale(false);
|
||||
}
|
||||
};
|
||||
|
||||
const queuePreview = async () => {
|
||||
if (!selectedNode) return;
|
||||
setIsRunningNode(true);
|
||||
try {
|
||||
if (projectBrainClientMode === "real") {
|
||||
const response = await realClient.blueprints.runBlueprintNode(plan.projectId, plan.id, selectedNode.id, {
|
||||
createDocumentation: true,
|
||||
sendToReview: true,
|
||||
});
|
||||
setNodeRun(response.data);
|
||||
blueprintResource.reload();
|
||||
showSuccess("Blueprint node generated, saved as documentation draft and sent to review.");
|
||||
return;
|
||||
}
|
||||
const preview = createBlueprintNodeRunPreview(plan, selectedNode);
|
||||
setNodeRun(preview);
|
||||
showSuccess("Blueprint draft preview generated locally; generated documents still require review before truth.");
|
||||
} catch (error) {
|
||||
appLogger.error("Blueprint node run persistence failed", error);
|
||||
showError("Blueprint node run could not be persisted; local preview remains available.");
|
||||
setNodeRun(createBlueprintNodeRunPreview(plan, selectedNode));
|
||||
} finally {
|
||||
setIsRunningNode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyDraft = async () => {
|
||||
if (!nodeRun) return;
|
||||
await navigator.clipboard.writeText(nodeRun.draftMarkdown);
|
||||
showSuccess("Blueprint draft copied.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Blueprint Engine"
|
||||
subtitle="Build a dynamic design-documentation graph for this project before generating AI/Codex-ready docs."
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BrainCircuit size={18} />
|
||||
Dynamic documentation blueprint
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
V56 turns the Blueprint Engine into a visible workbench: a guided launchpad, one-click next-document generation, root-doc batch generation, a document board, review/export shortcuts and clearer progress states make the feature feel usable instead of buried in contracts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<div className="rounded-md border p-3 md:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">Detected archetype</p>
|
||||
<p className="text-lg font-semibold">{plan.archetype.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plan.archetype.summary}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">Blueprint progress</p>
|
||||
<p className="text-2xl font-semibold">{progressPercent}%</p>
|
||||
<p className="text-xs text-muted-foreground">{acceptedNodes}/{plan.totalNodes} accepted</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">Needs review</p>
|
||||
<p className="text-2xl font-semibold">{reviewNodes}</p>
|
||||
<p className="text-xs text-muted-foreground">{dependencyCoverage}% dependency-ready</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">Context / stale</p>
|
||||
<p className="text-2xl font-semibold">{contextCoverage}%</p>
|
||||
<p className="text-xs text-muted-foreground">{staleNodes} stale · {blockedNodes} blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="w-full max-w-xs">
|
||||
<Select value={depth} onValueChange={(value) => setDepth(value as BlueprintDepth | "auto")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Blueprint depth" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto — {depthLabels[plan.archetype.recommendedDepth]}</SelectItem>
|
||||
<SelectItem value="light">Light — minimum viable blueprint</SelectItem>
|
||||
<SelectItem value="standard">Standard — normal product docs</SelectItem>
|
||||
<SelectItem value="deep">Deep — AI/Codex-ready project docs</SelectItem>
|
||||
<SelectItem value="full">Full — exhaustive professional docs</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Badge variant="outline">Root: {plan.documentRoot}</Badge>
|
||||
<Badge variant={plan.blockedNodes ? "destructive" : "default"}>{plan.status}</Badge>
|
||||
<Badge variant="secondary">{plan.clusters.length} clusters</Badge>
|
||||
<Badge variant="outline">{acceptedNodes} accepted</Badge>
|
||||
<Badge variant={staleNodes ? "destructive" : "outline"}>{staleNodes} stale</Badge>
|
||||
<Badge variant={projectBrainClientMode === "real" ? "default" : "outline"}>{projectBrainClientMode === "real" ? "database-backed" : "local preview"}</Badge>
|
||||
{blueprintResource.isLoading ? <Badge variant="outline">loading</Badge> : null}
|
||||
{blueprintResource.error ? <Badge variant="destructive">backend fallback</Badge> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Accepted documentation coverage</span>
|
||||
<span>{acceptedNodes}/{plan.totalNodes} nodes</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles size={18} />
|
||||
Blueprint Launchpad
|
||||
</CardTitle>
|
||||
<CardDescription>Visible, clickable progress: plan the docs, generate the next draft, review it, export the repo bundle, then keep stale docs fresh.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
{blueprintStages.map((stage) => (
|
||||
<div key={stage.title} className={stage.status === "active" ? "rounded-lg border border-primary bg-background p-3 shadow-sm" : "rounded-lg border bg-background/70 p-3"}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">{stage.title}</p>
|
||||
<Badge variant={stage.status === "active" ? "default" : stage.status === "blocked" ? "destructive" : "outline"}>{stage.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{stage.summary}</p>
|
||||
<p className="mt-3 text-xs font-medium">{stage.action}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
<Button className="justify-start" onClick={generateNextReadyNode} disabled={isGeneratingNext || !nextReadyNode}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
Generate next doc
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={generateRootBatch} disabled={isGeneratingRootBatch || !rootRunnableNodes.length}>
|
||||
<UploadCloud size={14} className="mr-2" />
|
||||
Generate root batch
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={() => navigate(reviewPath)}>
|
||||
<ListChecks size={14} className="mr-2" />
|
||||
Review docs
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={downloadRepoZip}>
|
||||
<Download size={14} className="mr-2" />
|
||||
Download repo zip
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={regenerateStaleNodes} disabled={isRegeneratingStale}>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
Refresh stale
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Next document</p><p className="font-semibold">{nextReadyNode?.title ?? "None"}</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Root batch ready</p><p className="font-semibold">{rootRunnableNodes.length} docs</p></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Review queue target</p><button className="font-semibold underline-offset-4 hover:underline" onClick={() => navigate(reviewPath)}>{reviewNodes} waiting</button></div>
|
||||
<div className="rounded-md border bg-background p-3"><p className="text-xs text-muted-foreground">Documentation target</p><button className="font-semibold underline-offset-4 hover:underline" onClick={() => navigate(docsPath)}>Open hub</button></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderTree size={18} />
|
||||
Documentation clusters
|
||||
</CardTitle>
|
||||
<CardDescription>Each cluster becomes part of the future repo documentation map.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plan.clusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className={selectedCluster === cluster.id ? "rounded-md border border-primary bg-primary/5 p-3" : "rounded-md border p-3"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium">{cluster.label}</p>
|
||||
<Badge variant="outline">{cluster.planned}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Ready/planned: {cluster.ready}. Blocked: {cluster.blocked}.</p>
|
||||
<p className="mt-2 truncate text-xs text-muted-foreground">{cluster.exportPaths[0]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="graph" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="graph">Graph</TabsTrigger>
|
||||
<TabsTrigger value="visual">Visual map</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents board</TabsTrigger>
|
||||
<TabsTrigger value="export">Export map</TabsTrigger>
|
||||
<TabsTrigger value="actions">Next actions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="graph" className="space-y-3">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 pt-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Graph workbench</p>
|
||||
<p className="text-xs text-muted-foreground">Showing {filteredNodes.length}/{plan.nodes.length} nodes. Filter by state or cluster and then regenerate the selected node directly.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as BlueprintNode["status"] | "all")}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="ready">Ready</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="needs_review">Needs review</SelectItem>
|
||||
<SelectItem value="accepted">Accepted</SelectItem>
|
||||
<SelectItem value="stale">Stale</SelectItem>
|
||||
<SelectItem value="blocked">Blocked</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={clusterFilter} onValueChange={setClusterFilter}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Cluster" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All clusters</SelectItem>
|
||||
{plan.clusters.map((cluster) => <SelectItem key={cluster.id} value={cluster.id}>{cluster.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{filteredNodes.map((node) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={node.id === selectedNode?.id ? "cursor-pointer border-primary" : "cursor-pointer"}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{node.generationOrder}. {node.title}</CardTitle>
|
||||
<CardDescription>{node.purpose}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<Badge variant={statusVariant(node.status)}>{node.status}</Badge>
|
||||
<Badge variant="outline">{node.cluster}</Badge>
|
||||
<Badge variant={scoreVariant(node.contextSelection.candidates[0]?.score ?? 0)}>
|
||||
ctx {node.contextSelection.candidates[0]?.score ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground">Depends on</p>
|
||||
<p>{node.dependencyTitles.length ? node.dependencyTitles.join(" → ") : "First domino / root document"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground">Export path</p>
|
||||
<p className="break-all">{node.outputPath}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ArrowRight size={14} />
|
||||
<span>{node.contextSelection.decisionSummary}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="visual">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Map size={18} />
|
||||
Visual blueprint map
|
||||
</CardTitle>
|
||||
<CardDescription>A compact dependency lane: root docs unlock downstream product, architecture, implementation and quality documents.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{plan.nodes.map((node) => {
|
||||
const downstreamCount = plan.nodes.filter((candidate) => candidate.dependsOn.includes(node.id)).length;
|
||||
return (
|
||||
<div key={node.id} className={node.id === selectedNode?.id ? "rounded-lg border border-primary bg-primary/5 p-3" : "rounded-lg border p-3"}>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold">{node.generationOrder}</div>
|
||||
<div>
|
||||
<button className="text-left font-medium hover:underline" onClick={() => setSelectedNodeId(node.id)}>{node.title}</button>
|
||||
<p className="text-xs text-muted-foreground">{node.dependsOn.length ? `Depends on ${node.dependsOn.join(", ")}` : "Root domino"} · unlocks {downstreamCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<Badge variant={statusVariant(node.status)}>{node.status}</Badge>
|
||||
<Badge variant="outline">{node.cluster}</Badge>
|
||||
{node.id === selectedNode?.id ? <Badge variant="secondary">selected</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="documents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText size={18} />
|
||||
Blueprint documents board
|
||||
</CardTitle>
|
||||
<CardDescription>Every planned design document is now visible as work, not just as graph metadata. Use this board to generate, review, regenerate and export docs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredNodes.map((node) => {
|
||||
const isActionable = node.status === "ready" || node.status === "pending" || node.status === "stale";
|
||||
return (
|
||||
<div key={node.id} className={node.id === selectedNode?.id ? "rounded-lg border border-primary bg-primary/5 p-3" : "rounded-lg border p-3"}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{node.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{node.outputPath}</p>
|
||||
</div>
|
||||
<Badge variant={statusVariant(node.status)}>{node.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded border bg-background p-2"><p className="text-muted-foreground">Cluster</p><p className="font-medium">{node.cluster}</p></div>
|
||||
<div className="rounded border bg-background p-2"><p className="text-muted-foreground">Context</p><p className="font-medium">{node.contextSelection.selectedContextPackIds.length} packs</p></div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedNodeId(node.id)}>Inspect</Button>
|
||||
<Button size="sm" disabled={!isActionable || isRunningNode} onClick={async () => { await generateNode(node, `${node.title} generated and sent to review.`); }}>
|
||||
{node.status === "stale" ? "Regenerate" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ClipboardList size={18} />
|
||||
Repo-ready documentation manifest
|
||||
</CardTitle>
|
||||
<CardDescription>This is the future folder structure that can later be exported as files or a zip.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea className="min-h-[320px] font-mono text-xs" value={exportBundle?.manifestMarkdown ?? indexMarkdown} readOnly />
|
||||
<div className={includeDraftsInExport ? "rounded-md border border-amber-500/40 bg-amber-500/10 p-3" : "rounded-md border border-primary/30 bg-primary/5 p-3"}>
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldCheck size={16} className="mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Export safety</p>
|
||||
<p className="text-sm text-muted-foreground">{exportRiskLevel}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{exportableNodes} exportable nodes · {acceptedNodes} accepted · {reviewNodes} in review · {staleNodes} stale.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={copyExportManifest}>Copy planning manifest</Button>
|
||||
<Button onClick={prepareRepoExport}>
|
||||
<Download size={14} className="mr-2" />
|
||||
Prepare repo export
|
||||
</Button>
|
||||
<Button variant="outline" onClick={downloadRepoZip}>
|
||||
<Download size={14} className="mr-2" />
|
||||
Download repo zip
|
||||
</Button>
|
||||
<Button variant={includeDraftsInExport ? "default" : "outline"} onClick={() => setIncludeDraftsInExport((value) => !value)}>
|
||||
{includeDraftsInExport ? "Drafts included" : "Accepted only"}
|
||||
</Button>
|
||||
</div>
|
||||
{exportBundle ? (
|
||||
<div className="grid gap-2 md:grid-cols-4">
|
||||
<div className="rounded-md border p-3 text-sm"><p className="text-xs text-muted-foreground">Files</p><p className="text-lg font-semibold">{exportBundle.files.length}</p></div>
|
||||
<div className="rounded-md border p-3 text-sm"><p className="text-xs text-muted-foreground">Accepted</p><p className="text-lg font-semibold">{exportBundle.acceptedCount}</p></div>
|
||||
<div className="rounded-md border p-3 text-sm"><p className="text-xs text-muted-foreground">Drafts</p><p className="text-lg font-semibold">{exportBundle.draftCount}</p></div>
|
||||
<div className="rounded-md border p-3 text-sm"><p className="text-xs text-muted-foreground">Skipped</p><p className="text-lg font-semibold">{exportBundle.skippedCount}</p></div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 size={18} />
|
||||
Recommended next actions
|
||||
</CardTitle>
|
||||
<CardDescription>The Blueprint Engine remains review-gated; this pass does not silently create accepted truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{plan.recommendedNextActions.map((action) => (
|
||||
<div key={action} className="flex gap-3 rounded-md border p-3 text-sm">
|
||||
<CheckCircle2 size={16} className="mt-0.5 text-primary" />
|
||||
<p>{action}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button onClick={generateNextReadyNode} disabled={isGeneratingNext || !nextReadyNode}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
Generate next ready doc
|
||||
</Button>
|
||||
<Button variant="outline" onClick={generateRootBatch} disabled={isGeneratingRootBatch || !rootRunnableNodes.length}>
|
||||
<UploadCloud size={14} className="mr-2" />
|
||||
Generate root batch
|
||||
</Button>
|
||||
<Button onClick={queuePreview} disabled={isRunningNode}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
{projectBrainClientMode === "real" ? "Generate selected + send to review" : "Preview selected draft"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={regenerateStaleNodes} disabled={isRegeneratingStale}>
|
||||
<UploadCloud size={14} className="mr-2" />
|
||||
Regenerate stale nodes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={regenerateSelectedNode} disabled={isRegeneratingSelected || !selectedNode}>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
Regenerate selected node
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectedNode && navigate(`/projects/${project.id}/brainstorm?scope=documentation&mode=structure&targetEntityType=blueprintNode&targetEntityId=${selectedNode.id}&title=${encodeURIComponent(selectedNode.title)}&prompt=${encodeURIComponent(selectedNode.purpose)}`)}
|
||||
disabled={!selectedNode}
|
||||
>
|
||||
<BrainCircuit size={14} className="mr-2" />
|
||||
Brainstorm selected node
|
||||
</Button>
|
||||
<Button variant="outline" onClick={copyExportManifest}>Copy manifest</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Network size={18} />
|
||||
Node contract
|
||||
</CardTitle>
|
||||
<CardDescription>Every document node is a small contract, not a vague one-shot prompt.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-semibold">{selectedNode.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedNode.kind} · order {selectedNode.generationOrder}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<Badge variant={statusVariant(selectedNode.status)}>{selectedNode.status}</Badge>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-xs text-muted-foreground">Depth</p>
|
||||
<p>{depthLabels[selectedNode.requiredDepth]}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-xs text-muted-foreground">Primary context</p>
|
||||
<p className="truncate">{selectedNode.contextSelection.primaryContextPackId ?? "none"}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-xs text-muted-foreground">Selected packs</p>
|
||||
<p>{selectedNode.contextSelection.selectedContextPackIds.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={queuePreview} disabled={isRunningNode}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
Generate draft
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={regenerateSelectedNode} disabled={isRegeneratingSelected}>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
Regenerate this node
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{impactReport ? (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground">Staleness impact</p>
|
||||
<p>{impactReport.summary}</p>
|
||||
{impactReport.affectedExportPaths.length ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{impactReport.affectedExportPaths.slice(0, 3).join(", ")}{impactReport.affectedExportPaths.length > 3 ? " …" : ""}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2"><Layers3 size={14} /> Required context</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedNode.requiredContext.map((entry) => <Badge key={entry} variant="outline">{entry}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2"><TriangleAlert size={14} /> Quality checks</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-sm text-muted-foreground">
|
||||
{selectedNode.qualityChecks.map((entry) => <li key={entry}>{entry}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2"><GitBranch size={14} /> Produces</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-sm text-muted-foreground">
|
||||
{selectedNode.produces.map((entry) => <li key={entry}>{entry}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2"><FileText size={14} /> Prompt preview</p>
|
||||
<Textarea className="min-h-[220px] font-mono text-xs" value={selectedNode.promptPreview} readOnly />
|
||||
</div>
|
||||
|
||||
{nodeRun ? (
|
||||
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium flex items-center gap-2"><UploadCloud size={14} /> Latest node run</p>
|
||||
<Badge variant="secondary">{nodeRun.status}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded border bg-background p-2">
|
||||
<p className="text-muted-foreground">Quality score</p>
|
||||
<p className="font-semibold">{nodeRun.qualityReport.score}</p>
|
||||
</div>
|
||||
<div className="rounded border bg-background p-2">
|
||||
<p className="text-muted-foreground">Review item</p>
|
||||
<p className="truncate font-semibold">{nodeRun.reviewItemId ?? "preview only"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea className="min-h-[240px] font-mono text-xs" value={nodeRun.draftMarkdown} readOnly />
|
||||
<Button variant="outline" onClick={copyDraft}>Copy generated draft</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlueprintEngine;
|
||||
@@ -0,0 +1,864 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Flag,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Network,
|
||||
Plus,
|
||||
Rocket,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Input,
|
||||
Label,
|
||||
PageHeader,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from "@/components/ui";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { realClient } from "@/shared/api/realClient";
|
||||
import { useProjectBrainstormsData } from "@/shared/hooks/useProjectData";
|
||||
import type { BrainstormIdea, BrainstormMode, BrainstormScope, BrainstormSession } from "@/shared/lib/model";
|
||||
import {
|
||||
brainstormModeOptions,
|
||||
brainstormScopeOptions,
|
||||
createBrainstormIdeaGraphSummary,
|
||||
createBrainstormMarkdown,
|
||||
createBrainstormRoadmapMarkdown,
|
||||
createBrainstormSession,
|
||||
createProjectDraftFromBrainstorm,
|
||||
summarizeBrainstorm,
|
||||
} from "@/shared/lib/brainstormEngine";
|
||||
|
||||
const downloadTextFile = (filename: string, content: string) => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const angleClass: Record<BrainstormIdea["angle"], string> = {
|
||||
core: "bg-primary/10 text-primary border-primary/20",
|
||||
ux: "bg-blue-500/10 text-blue-700 border-blue-500/20",
|
||||
technical: "bg-violet-500/10 text-violet-700 border-violet-500/20",
|
||||
risk: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
||||
growth: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
|
||||
wildcard: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20",
|
||||
implementation: "bg-slate-900/10 text-slate-700 border-slate-900/20",
|
||||
};
|
||||
|
||||
const isRealMode = projectBrainClientMode === "real";
|
||||
|
||||
const toBrainstormPayload = (session: BrainstormSession) => ({
|
||||
title: session.title,
|
||||
prompt: session.prompt,
|
||||
scope: session.scope,
|
||||
mode: session.mode,
|
||||
status: session.status,
|
||||
targetEntityId: session.targetEntityId,
|
||||
targetEntityType: session.targetEntityType,
|
||||
generatedBy: session.generatedBy,
|
||||
contextSummary: session.contextSummary,
|
||||
ideas: session.ideas,
|
||||
clusters: session.clusters,
|
||||
outcomes: session.outcomes,
|
||||
selectedIdeaIds: session.selectedIdeaIds,
|
||||
nextActions: session.nextActions,
|
||||
});
|
||||
|
||||
|
||||
const BrainstormLab = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const {
|
||||
projects,
|
||||
createBrainstorm,
|
||||
updateBrainstorm,
|
||||
convertBrainstormToFeature,
|
||||
convertBrainstormToDocumentation,
|
||||
convertBrainstormToContextPack,
|
||||
convertBrainstormToBlueprintSeed,
|
||||
convertBrainstormToInitiative,
|
||||
} = useProject();
|
||||
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const localSessions = useMemo(() => project?.brainstorms ?? [], [project]);
|
||||
const brainstormsData = useProjectBrainstormsData(project?.id, localSessions);
|
||||
const sessions = brainstormsData.data ?? localSessions;
|
||||
const [actionMessage, setActionMessage] = useState<string | undefined>();
|
||||
const initialPrompt = searchParams.get("prompt") ?? "";
|
||||
const initialScope = (searchParams.get("scope") as BrainstormScope | null) ?? "feature";
|
||||
const initialMode = (searchParams.get("mode") as BrainstormMode | null) ?? "diverge";
|
||||
const [title, setTitle] = useState(searchParams.get("title") ?? "");
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const [scope, setScope] = useState<BrainstormScope>(initialScope);
|
||||
const [mode, setMode] = useState<BrainstormMode>(initialMode);
|
||||
const [query, setQuery] = useState("");
|
||||
const [ideaAngleFilter, setIdeaAngleFilter] = useState("all");
|
||||
const [ideaImpactFilter, setIdeaImpactFilter] = useState("all");
|
||||
const [ideaStatusFilter, setIdeaStatusFilter] = useState("all");
|
||||
const [selectedSessionId, setSelectedSessionId] = useState(searchParams.get("sessionId") ?? sessions[0]?.id ?? "");
|
||||
|
||||
const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? sessions[0];
|
||||
const filteredSessions = sessions.filter((session) =>
|
||||
`${session.title} ${session.prompt} ${session.scope} ${session.mode}`.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
const sessionSummary = selectedSession ? summarizeBrainstorm(selectedSession) : undefined;
|
||||
const selectedIdeas = selectedSession?.ideas.filter((idea) => selectedSession.selectedIdeaIds.includes(idea.id)) ?? [];
|
||||
const ideaMatchesFilters = (idea: BrainstormIdea) =>
|
||||
(ideaAngleFilter === "all" || idea.angle === ideaAngleFilter) &&
|
||||
(ideaImpactFilter === "all" || idea.impact === ideaImpactFilter) &&
|
||||
(ideaStatusFilter === "all" || (ideaStatusFilter === "selected" ? selectedSession?.selectedIdeaIds.includes(idea.id) : idea.status === ideaStatusFilter));
|
||||
const filteredIdeas = selectedSession?.ideas.filter(ideaMatchesFilters) ?? [];
|
||||
const filteredSelectedIdeas = selectedIdeas.filter(ideaMatchesFilters);
|
||||
const comparableSessions = sessions.filter((session) => session.id !== selectedSession?.id).slice(0, 4);
|
||||
const sharedAngleRows = selectedSession ? comparableSessions.map((session) => {
|
||||
const selectedAngles = new Set(selectedSession.ideas.map((idea) => idea.angle));
|
||||
const otherAngles = new Set(session.ideas.map((idea) => idea.angle));
|
||||
const overlap = [...selectedAngles].filter((angle) => otherAngles.has(angle));
|
||||
return { session, overlap, selectedCount: session.selectedIdeaIds.length, averageScore: summarizeBrainstorm(session).averageScore };
|
||||
}) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId && sessions[0]?.id) setSelectedSessionId(sessions[0].id);
|
||||
}, [selectedSessionId, sessions]);
|
||||
|
||||
const projectName = project?.name ?? "Project";
|
||||
const canCreate = Boolean(project && (title.trim().length > 2 || prompt.trim().length > 8));
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!project || !canCreate) return;
|
||||
const seed = {
|
||||
title: title.trim() || `Brainstorm for ${project.name}`,
|
||||
prompt: prompt.trim() || title.trim(),
|
||||
scope,
|
||||
mode,
|
||||
targetEntityId: searchParams.get("targetEntityId") ?? undefined,
|
||||
targetEntityType: (searchParams.get("targetEntityType") as BrainstormSession["targetEntityType"] | null) ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isRealMode) {
|
||||
const previewSession = createBrainstormSession(project, seed, "manual");
|
||||
const created = (await realClient.brainstorms.createBrainstorm(project.id, toBrainstormPayload(previewSession))).data;
|
||||
setSelectedSessionId(created.id);
|
||||
brainstormsData.reload();
|
||||
setActionMessage("Brainstorm saved to the backend database.");
|
||||
} else {
|
||||
const session = createBrainstorm(project.id, seed);
|
||||
if (session) setSelectedSessionId(session.id);
|
||||
setActionMessage("Brainstorm saved to local project memory.");
|
||||
}
|
||||
setTitle("");
|
||||
setPrompt("");
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "Could not create brainstorm.");
|
||||
}
|
||||
};
|
||||
|
||||
const patchSession = async (session: BrainstormSession) => {
|
||||
if (!project) return;
|
||||
if (isRealMode) {
|
||||
try {
|
||||
await realClient.brainstorms.updateBrainstorm(project.id, session.id, toBrainstormPayload(session));
|
||||
brainstormsData.reload();
|
||||
setActionMessage("Brainstorm changes saved to database.");
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "Could not save brainstorm changes.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateBrainstorm(project.id, session);
|
||||
};
|
||||
|
||||
const toggleIdea = (ideaId: string) => {
|
||||
if (!selectedSession) return;
|
||||
const selected = new Set(selectedSession.selectedIdeaIds);
|
||||
if (selected.has(ideaId)) selected.delete(ideaId);
|
||||
else selected.add(ideaId);
|
||||
void patchSession({
|
||||
...selectedSession,
|
||||
status: selected.size ? "shortlisted" : "exploring",
|
||||
selectedIdeaIds: Array.from(selected),
|
||||
ideas: selectedSession.ideas.map((idea) =>
|
||||
idea.id === ideaId
|
||||
? { ...idea, status: selected.has(ideaId) ? "selected" : "raw" }
|
||||
: idea,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const convertInRealMode = async (target: "feature" | "documentation" | "context_pack" | "blueprint_node" | "initiative", fallbackRoute: string) => {
|
||||
if (!project || !selectedSession) return;
|
||||
try {
|
||||
const result = await realClient.brainstorms.convertBrainstorm(project.id, selectedSession.id, target);
|
||||
setActionMessage(result.data.message);
|
||||
await brainstormsData.reload();
|
||||
navigate(result.data.targetRoute ?? fallbackRoute);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "Could not convert brainstorm.");
|
||||
}
|
||||
};
|
||||
|
||||
const convertSelected = () => {
|
||||
if (!project || !selectedSession) return;
|
||||
if (isRealMode) {
|
||||
void convertInRealMode("feature", `/projects/${project.id}/features`);
|
||||
return;
|
||||
}
|
||||
convertBrainstormToFeature(project.id, selectedSession.id);
|
||||
navigate(`/projects/${project.id}/features`);
|
||||
};
|
||||
|
||||
const convertDocumentation = () => {
|
||||
if (!project || !selectedSession) return;
|
||||
if (isRealMode) {
|
||||
void convertInRealMode("documentation", `/projects/${project.id}/documentation`);
|
||||
return;
|
||||
}
|
||||
convertBrainstormToDocumentation(project.id, selectedSession.id);
|
||||
navigate(`/projects/${project.id}/documentation`);
|
||||
};
|
||||
|
||||
const convertContext = () => {
|
||||
if (!project || !selectedSession) return;
|
||||
if (isRealMode) {
|
||||
void convertInRealMode("context_pack", `/projects/${project.id}/context-packs`);
|
||||
return;
|
||||
}
|
||||
convertBrainstormToContextPack(project.id, selectedSession.id);
|
||||
navigate(`/projects/${project.id}/context-packs`);
|
||||
};
|
||||
|
||||
const convertBlueprint = () => {
|
||||
if (!project || !selectedSession) return;
|
||||
if (isRealMode) {
|
||||
void convertInRealMode("blueprint_node", `/projects/${project.id}/blueprint?brainstormId=${selectedSession.id}`);
|
||||
return;
|
||||
}
|
||||
convertBrainstormToBlueprintSeed(project.id, selectedSession.id);
|
||||
navigate(`/projects/${project.id}/blueprint?brainstormId=${selectedSession.id}`);
|
||||
};
|
||||
|
||||
const convertInitiative = () => {
|
||||
if (!project || !selectedSession) return;
|
||||
if (isRealMode) {
|
||||
void convertInRealMode("initiative", `/projects/${project.id}/initiatives`);
|
||||
return;
|
||||
}
|
||||
convertBrainstormToInitiative(project.id, selectedSession.id);
|
||||
navigate(`/projects/${project.id}/initiatives`);
|
||||
};
|
||||
|
||||
const openProjectSetupPrefill = () => {
|
||||
if (!selectedSession) return;
|
||||
const params = new URLSearchParams({
|
||||
source: "brainstorm",
|
||||
name: selectedSession.title,
|
||||
description: selectedSession.prompt,
|
||||
primaryGoal: selectedSession.ideas.find((idea) => selectedSession.selectedIdeaIds.includes(idea.id))?.title ?? selectedSession.title,
|
||||
firstSlice: selectedSession.ideas.find((idea) => selectedSession.selectedIdeaIds.includes(idea.id))?.nextStep ?? "Build the smallest reviewable slice from this brainstorm.",
|
||||
});
|
||||
navigate(`/projects/new?${params.toString()}`);
|
||||
};
|
||||
|
||||
const markdown = useMemo(() => selectedSession ? createBrainstormMarkdown(selectedSession) : "", [selectedSession]);
|
||||
const projectDraftMarkdown = useMemo(() => selectedSession ? createProjectDraftFromBrainstorm(selectedSession) : "", [selectedSession]);
|
||||
const roadmapMarkdown = useMemo(() => selectedSession ? createBrainstormRoadmapMarkdown(selectedSession) : "", [selectedSession]);
|
||||
const graphSummary = useMemo(() => selectedSession ? createBrainstormIdeaGraphSummary(selectedSession) : undefined, [selectedSession]);
|
||||
|
||||
if (!project) {
|
||||
return <EmptyState icon={BrainCircuit} title="No project selected" description="Open or create a project before starting a brainstorm." action={{ label: "Open projects", onClick: () => navigate("/projects") }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Brainstorm CPU"
|
||||
subtitle="Explore ideas, cluster options, and convert the best outcomes into review-gated project or feature work."
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="gap-2" onClick={handleCreate} disabled={!canCreate}>
|
||||
<WandSparkles size={14} /> Run brainstorm
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={() => navigate(`/projects/${project.id}/review-queue`)}>
|
||||
<CheckCircle2 size={14} /> Open Review Queue
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
<Badge variant={isRealMode ? "default" : "outline"}>{isRealMode ? "Database-backed Brainstorm CPU" : "Local Brainstorm CPU"}</Badge>
|
||||
{brainstormsData.isLoading ? <span>Loading brainstorm memory...</span> : <span>{sessions.length} saved sessions available.</span>}
|
||||
{brainstormsData.error ? <span className="text-destructive">{brainstormsData.error.message}</span> : null}
|
||||
{selectedSession ? <span>Active: {selectedSession.status}</span> : null}
|
||||
{actionMessage ? <span>{actionMessage}</span> : null}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="gap-2"><BrainCircuit size={14} /> Extra CPU</Badge>
|
||||
<Badge variant="outline">{projectName}</Badge>
|
||||
</div>
|
||||
<CardTitle>Start a structured brainstorm</CardTitle>
|
||||
<CardDescription>
|
||||
Use this for full project ideas, feature concepts, specific UX parts, architecture alternatives or documentation strategy.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="brainstorm-title">Idea title</Label>
|
||||
<Input id="brainstorm-title" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="e.g. Ultimate design documentation builder" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Scope</Label>
|
||||
<Select value={scope} onValueChange={(value) => setScope(value as BrainstormScope)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{brainstormScopeOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_260px]">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="brainstorm-prompt">What should the Brainstorm CPU explore?</Label>
|
||||
<Textarea id="brainstorm-prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} className="min-h-36" placeholder="Describe the rough idea, problem, feature, project or uncertain part. More context gives better clusters and conversion candidates." />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Mode</Label>
|
||||
<Select value={mode} onValueChange={(value) => setMode(value as BrainstormMode)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{brainstormModeOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="rounded-xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{brainstormModeOptions.find((option) => option.value === mode)?.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/25 p-3">
|
||||
<p className="text-sm font-semibold">Brainstorm templates</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Start faster with a focused thinking mode.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[
|
||||
["New project", "project", "diverge", "Explore a new project idea from multiple angles."],
|
||||
["Next feature", "feature", "implementation", "Find the smallest valuable feature slice."],
|
||||
["Architecture risk", "architecture", "stress-test", "Stress-test architecture and identify weak spots."],
|
||||
["Missing docs", "documentation", "structure", "Find missing or stale documentation."],
|
||||
].map(([label, nextScope, nextMode, nextPrompt]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTitle(label);
|
||||
setScope(nextScope as BrainstormScope);
|
||||
setMode(nextMode as BrainstormMode);
|
||||
setPrompt(nextPrompt);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Brainstorm memory</CardTitle>
|
||||
<CardDescription>Saved sessions can later become projects, features, blueprint docs or review items.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div className="rounded-xl border p-3"><p className="text-muted-foreground">Sessions</p><p className="text-2xl font-semibold">{sessions.length}</p></div>
|
||||
<div className="rounded-xl border p-3"><p className="text-muted-foreground">Selected ideas</p><p className="text-2xl font-semibold">{sessions.reduce((total, session) => total + session.selectedIdeaIds.length, 0)}</p></div>
|
||||
<div className="rounded-xl border p-3"><p className="text-muted-foreground">Converted</p><p className="text-2xl font-semibold">{sessions.filter((session) => session.status === "converted").length}</p></div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="mt-1 text-primary" size={18} />
|
||||
<div>
|
||||
<p className="font-semibold">How it fits ProjectBrain</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Brainstorms are not accepted truth. They are an exploration layer that can create review-gated candidates for features, docs, context packs or new projects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Saved brainstorms</CardTitle>
|
||||
<CardDescription>Search and reopen previous exploration sessions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 text-muted-foreground" size={15} />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search brainstorms" className="pl-9" />
|
||||
</div>
|
||||
{filteredSessions.length === 0 ? (
|
||||
<EmptyState icon={Lightbulb} title="No brainstorms yet" description="Run the first session above to create reusable idea memory." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
type="button"
|
||||
key={session.id}
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition-colors ${selectedSession?.id === session.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{session.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{session.prompt}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{session.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
<Badge variant="secondary">{session.scope}</Badge>
|
||||
<Badge variant="secondary">{session.mode}</Badge>
|
||||
<Badge variant="secondary">{session.ideas.length} ideas</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedSession ? (
|
||||
<Tabs defaultValue="ideas" className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-2xl border bg-background p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="gap-2"><Target size={14} /> {selectedSession.scope}</Badge>
|
||||
<Badge variant="outline">{selectedSession.mode}</Badge>
|
||||
<Badge variant="outline">avg {sessionSummary?.averageScore ?? 0}/100</Badge>
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-semibold">{selectedSession.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedSession.contextSummary}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" className="gap-2" onClick={() => navigator.clipboard.writeText(markdown)}><Copy size={14} /> Copy</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={() => downloadTextFile(`${selectedSession.id}.md`, markdown)}><Download size={14} /> Export</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={openProjectSetupPrefill}><Rocket size={14} /> New project setup</Button>
|
||||
<Button className="gap-2" onClick={convertSelected} disabled={selectedSession.status === "converted"}><ArrowRight size={14} /> Convert to feature</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-8">
|
||||
<TabsTrigger value="ideas">Ideas</TabsTrigger>
|
||||
<TabsTrigger value="clusters">Clusters</TabsTrigger>
|
||||
<TabsTrigger value="graph">Idea graph</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
<TabsTrigger value="compare">Compare</TabsTrigger>
|
||||
<TabsTrigger value="outcomes">Outcomes</TabsTrigger>
|
||||
<TabsTrigger value="conversions">Convert</TabsTrigger>
|
||||
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ideas" className="space-y-3">
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-4">
|
||||
<div>
|
||||
<Label>Angle</Label>
|
||||
<Select value={ideaAngleFilter} onValueChange={setIdeaAngleFilter}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All angles</SelectItem>
|
||||
{Object.keys(angleClass).map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Impact</Label>
|
||||
<Select value={ideaImpactFilter} onValueChange={setIdeaImpactFilter}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All impact</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Select value={ideaStatusFilter} onValueChange={setIdeaStatusFilter}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All status</SelectItem>
|
||||
<SelectItem value="selected">Selected</SelectItem>
|
||||
<SelectItem value="promising">Promising</SelectItem>
|
||||
<SelectItem value="raw">Raw</SelectItem>
|
||||
<SelectItem value="converted">Converted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" className="w-full" onClick={() => { setIdeaAngleFilter("all"); setIdeaImpactFilter("all"); setIdeaStatusFilter("all"); }}>Reset filters</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{filteredIdeas.length === 0 ? <EmptyState icon={Search} title="No ideas match these filters" description="Reset filters or select another brainstorm session." /> : null}
|
||||
{filteredIdeas.map((idea) => (
|
||||
<Card key={idea.id} className={selectedSession.selectedIdeaIds.includes(idea.id) ? "border-primary" : undefined}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className={angleClass[idea.angle]} variant="outline">{idea.angle}</Badge>
|
||||
<Badge variant="secondary">score {idea.score}</Badge>
|
||||
<Badge variant="secondary">{idea.impact} impact</Badge>
|
||||
<Badge variant="secondary">{idea.effort} effort</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold">{idea.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{idea.description}</p>
|
||||
<p className="text-xs text-muted-foreground"><strong>Next:</strong> {idea.nextStep}</p>
|
||||
</div>
|
||||
<Button variant={selectedSession.selectedIdeaIds.includes(idea.id) ? "default" : "outline"} size="sm" onClick={() => toggleIdea(idea.id)}>
|
||||
{selectedSession.selectedIdeaIds.includes(idea.id) ? "Selected" : "Select"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="clusters" className="grid gap-3 md:grid-cols-2">
|
||||
{selectedSession.clusters.map((cluster) => (
|
||||
<Card key={cluster.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Network size={16} /> {cluster.title}</CardTitle>
|
||||
<CardDescription>{cluster.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<p><strong>Ideas:</strong> {cluster.ideaIds.length}</p>
|
||||
<p className="text-muted-foreground">{cluster.recommendedAction}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="graph" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Network size={16} /> Idea graph</CardTitle>
|
||||
<CardDescription>Trace how raw ideas cluster into candidate outcomes before anything becomes project truth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<span>Graph filters mirror the Ideas tab:</span>
|
||||
<Badge variant="outline">angle: {ideaAngleFilter}</Badge>
|
||||
<Badge variant="outline">impact: {ideaImpactFilter}</Badge>
|
||||
<Badge variant="outline">status: {ideaStatusFilter}</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-3 rounded-2xl border bg-muted/20 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Selected signals</p>
|
||||
{filteredSelectedIdeas.length === 0 ? <p className="text-sm text-muted-foreground">No selected ideas match the current filters.</p> : filteredSelectedIdeas.map((idea) => (
|
||||
<div key={idea.id} className="rounded-xl border bg-background p-3">
|
||||
<Badge className={angleClass[idea.angle]} variant="outline">{idea.angle}</Badge>
|
||||
<p className="mt-2 text-sm font-semibold">{idea.title}</p>
|
||||
<p className="text-xs text-muted-foreground">score {idea.score} • {idea.impact} impact</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border bg-muted/20 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Clusters</p>
|
||||
{selectedSession.clusters.map((cluster) => (
|
||||
<div key={cluster.id} className="rounded-xl border bg-background p-3">
|
||||
<p className="text-sm font-semibold">{cluster.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{cluster.ideaIds.filter((ideaId) => selectedSession.selectedIdeaIds.includes(ideaId)).length} selected signals feed this cluster.</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{cluster.recommendedAction}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border bg-muted/20 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outcome routes</p>
|
||||
{selectedSession.outcomes.map((outcome) => (
|
||||
<div key={outcome.id} className="rounded-xl border bg-background p-3">
|
||||
<div className="flex flex-wrap gap-1"><Badge>{outcome.type}</Badge><Badge variant="outline">{outcome.conversionStatus}</Badge></div>
|
||||
<p className="mt-2 text-sm font-semibold">{outcome.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{outcome.sourceIdeaIds.length} source ideas</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Flow rule: brainstorms are allowed to diverge freely, but every conversion still lands in Review Queue, Documentation Hub, Context Packs or Blueprint Engine before becoming accepted truth.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="history" className="space-y-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session timeline</CardTitle>
|
||||
<CardDescription>See how this idea moved from raw exploration to selected signals and conversion candidates.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[
|
||||
{ label: "Created", value: new Date(selectedSession.createdAt).toLocaleString(), detail: selectedSession.generatedBy },
|
||||
{ label: "Last updated", value: new Date(selectedSession.updatedAt).toLocaleString(), detail: selectedSession.status },
|
||||
{ label: "Selected signals", value: `${selectedSession.selectedIdeaIds.length} ideas`, detail: graphSummary?.selectedAngles.join(", ") || "No selected angles" },
|
||||
{ label: "Converted outcomes", value: `${graphSummary?.convertedOutcomes.length ?? 0} outcomes`, detail: graphSummary?.convertedOutcomes.map((item) => item.title).join(", ") || "Nothing converted yet" },
|
||||
].map((item, index) => (
|
||||
<div key={item.label} className="flex gap-3 rounded-xl border p-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">{index + 1}</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.label}: {item.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Roadmap preview</CardTitle>
|
||||
<CardDescription>Copy this when a brainstorm should become a planning or initiative artifact.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<pre className="max-h-80 overflow-auto rounded-xl bg-slate-950 p-4 text-xs text-slate-100">{roadmapMarkdown}</pre>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" className="gap-2" onClick={() => navigator.clipboard.writeText(roadmapMarkdown)}><Copy size={14} /> Copy roadmap</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={() => downloadTextFile(`${selectedSession.id}-roadmap.md`, roadmapMarkdown)}><Download size={14} /> Export roadmap</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
|
||||
<TabsContent value="compare" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session comparison</CardTitle>
|
||||
<CardDescription>Compare this brainstorm with recent sessions to find repeated angles, reusable signals and roadmap convergence.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sharedAngleRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No other sessions to compare yet. Run a few brainstorms to see convergence patterns.</p>
|
||||
) : sharedAngleRows.map(({ session, overlap, selectedCount, averageScore }) => (
|
||||
<div key={session.id} className="rounded-xl border p-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{session.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{session.scope} · {session.mode} · {selectedCount} selected signals</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">avg {averageScore}/100</Badge>
|
||||
<Badge variant={overlap.length ? "default" : "outline"}>{overlap.length} shared angle(s)</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{overlap.length ? overlap.map((angle) => <Badge key={angle} className={angleClass[angle as BrainstormIdea["angle"]]} variant="outline">{angle}</Badge>) : <span className="text-xs text-muted-foreground">No shared angles with the selected brainstorm.</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convergence recommendation</CardTitle>
|
||||
<CardDescription>Use repeated angles as candidates for roadmap initiatives, context packs or Blueprint nodes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>If the same angles appear across multiple brainstorms, ProjectBrain should treat them as stronger strategic signals. Convert them through Review Queue before adding them to accepted truth.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={convertInitiative} disabled={!selectedSession || selectedSession.status === "converted"}>
|
||||
<Flag size={14} className="mr-2" /> Merge current signals into initiative
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/initiatives`)}>Open Initiatives</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="outcomes" className="space-y-3">
|
||||
{selectedSession.outcomes.map((outcome) => (
|
||||
<Card key={outcome.id}>
|
||||
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2"><Badge>{outcome.type}</Badge><Badge variant="outline">{outcome.conversionStatus}</Badge></div>
|
||||
<h3 className="mt-2 font-semibold">{outcome.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{outcome.summary}</p>
|
||||
</div>
|
||||
{outcome.targetRoute ? (
|
||||
<Button variant="outline" onClick={() => navigate(outcome.targetRoute ?? "#")}>Open target</Button>
|
||||
) : (
|
||||
<Button onClick={() => {
|
||||
if (outcome.type === "project_candidate") openProjectSetupPrefill();
|
||||
else if (outcome.type === "documentation_candidate") convertDocumentation();
|
||||
else if (outcome.type === "context_pack_seed") convertContext();
|
||||
else if (outcome.type === "implementation_slice") convertBlueprint();
|
||||
else if (outcome.type === "initiative_candidate") convertInitiative();
|
||||
else convertSelected();
|
||||
}}>Convert</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="conversions" className="grid gap-3 md:grid-cols-2">
|
||||
<Card className="border-l-4 border-l-primary md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><CheckCircle2 size={16} /> Review-gated conversion pipeline</CardTitle>
|
||||
<CardDescription>Conversions create candidates and review items first. Accepting a Brainstorm CPU review item in database mode now also materializes accepted truth.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Rocket size={16} /> New project candidate</CardTitle>
|
||||
<CardDescription>Use this brainstorm as a prefilled project setup input.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best for large ideas that should become their own ProjectBrain workspace.</p>
|
||||
<Button className="w-full gap-2" onClick={openProjectSetupPrefill}><Rocket size={14} /> Open project setup</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><ArrowRight size={16} /> Feature candidate</CardTitle>
|
||||
<CardDescription>Create a review-gated feature with tasks and a proposed decision.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best when the idea belongs inside this project as implementation work.</p>
|
||||
<Button className="w-full gap-2" onClick={convertSelected} disabled={selectedSession.status === "converted"}><ArrowRight size={14} /> Convert to feature</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><FileText size={16} /> Documentation seed</CardTitle>
|
||||
<CardDescription>Send the idea into Documentation Hub and Review Queue.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best when the brainstorm should become durable design/specification material.</p>
|
||||
<Button className="w-full gap-2" onClick={convertDocumentation}><FileText size={14} /> Convert to docs</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><BrainCircuit size={16} /> Context pack seed</CardTitle>
|
||||
<CardDescription>Package selected ideas as reusable context for prompts and handoffs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best when the idea should influence future AI/Codex work without becoming accepted truth yet.</p>
|
||||
<Button className="w-full gap-2" onClick={convertContext}><BrainCircuit size={14} /> Convert to context</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Flag size={16} /> Roadmap initiative</CardTitle>
|
||||
<CardDescription>Merge selected outcomes into a roadmap-level initiative.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best when several ideas should coordinate future features, docs, and delivery work.</p>
|
||||
<Button className="w-full gap-2" onClick={convertInitiative}><Flag size={14} /> Merge into initiative</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Layers size={16} /> Blueprint node candidate</CardTitle>
|
||||
<CardDescription>Queue the brainstorm as a design-documentation node candidate.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Best when the idea should become part of the Blueprint Engine documentation graph.</p>
|
||||
<Button className="gap-2" onClick={convertBlueprint}><Layers size={14} /> Queue as blueprint seed</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="markdown">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><FileText size={16} /> Brainstorm export</CardTitle>
|
||||
<CardDescription>Use this as repo docs, context material, or AI handoff input.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard.writeText(projectDraftMarkdown)}>Copy project draft</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadTextFile(`${selectedSession.id}-project-candidate.md`, projectDraftMarkdown)}>Export project candidate</Button>
|
||||
</div>
|
||||
<pre className="max-h-[560px] overflow-auto rounded-xl bg-muted p-4 text-xs leading-5 whitespace-pre-wrap">{markdown}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<EmptyState icon={Rocket} title="No active brainstorm" description="Create a session or choose a template to see ideas, clusters, outcomes and conversion actions." action={{ label: "Use next feature template", onClick: () => { setTitle("Next feature"); setScope("feature"); setMode("implementation"); setPrompt("Find the smallest valuable feature slice and the safest review path."); } }} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Project path</CardTitle></CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">Use project-scope brainstorms to shape a new project direction or prepare a Start flow brief.</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Feature path</CardTitle></CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">Convert selected ideas into feature candidates with tasks and a review item.</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Documentation path</CardTitle></CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">Export brainstorm markdown or use outcomes as seeds for Blueprint Engine documents.</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrainstormLab;
|
||||
@@ -0,0 +1,488 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
ClipboardCopy,
|
||||
ExternalLink,
|
||||
FileJson2,
|
||||
MessageSquareText,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Textarea } from "@/components/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { BuildLoopActionRail } from "@/shared/components/BuildLoopActionRail";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import type { BrowserBridgePayload, BrowserBridgeSession, BrowserBridgeTarget } from "@/shared/lib/model";
|
||||
import {
|
||||
browserBridgeSafetyCopy,
|
||||
formatBridgeInstructionsMarkdown,
|
||||
getBrowserBridgeTargetLabel,
|
||||
getBrowserBridgeTargetUrl,
|
||||
parseBrowserBridgeResponse,
|
||||
} from "@/shared/lib/browserBridge";
|
||||
import { downloadTextFile } from "@/shared/lib/downloads";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
|
||||
const statusVariant = (status: BrowserBridgeSession["status"]) =>
|
||||
status === "feedback-created" ? "secondary" :
|
||||
status === "response-pasted" || status === "opened" || status === "copied" ? "default" :
|
||||
status === "archived" ? "outline" :
|
||||
"outline";
|
||||
|
||||
const readBridgePayload = (session: BrowserBridgeSession): BrowserBridgePayload | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(session.payloadJson) as Partial<BrowserBridgePayload>;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
typeof parsed.title !== "string" ||
|
||||
typeof parsed.projectId !== "string" ||
|
||||
typeof parsed.linkedSessionPackId !== "string" ||
|
||||
!Array.isArray(parsed.safetyNotes) ||
|
||||
!Array.isArray(parsed.validationCommands)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed as BrowserBridgePayload;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string, successMessage: string) => {
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
showError("Clipboard is unavailable. Use download or select the visible text manually.");
|
||||
return false;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
showSuccess(successMessage);
|
||||
return true;
|
||||
} catch {
|
||||
showError("Clipboard copy failed. Use download or select the visible text manually.");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserBridge = () => {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
findProjectById,
|
||||
createBrowserBridgeSession,
|
||||
updateBrowserBridgeSessionStatus,
|
||||
pasteBrowserBridgeResponse,
|
||||
createExecutionFeedbackFromBridge,
|
||||
} = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
const packs = useMemo(() => (project?.codexSessionPacks ?? []).filter((pack) => !pack.archived), [project]);
|
||||
const sessions = useMemo(() => (project?.browserBridgeSessions ?? []).filter((session) => !session.archived), [project]);
|
||||
const feedback = useMemo(() => (project?.codexExecutionFeedback ?? []).filter((item) => !item.archived), [project]);
|
||||
const [selectedPackId, setSelectedPackId] = useState(packs[0]?.id ?? "");
|
||||
const [selectedSessionId, setSelectedSessionId] = useState(sessions[0]?.id ?? "");
|
||||
const [target, setTarget] = useState<BrowserBridgeTarget>("chatgpt");
|
||||
const [responseText, setResponseText] = useState("");
|
||||
|
||||
if (!project || !projectId) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? sessions[0];
|
||||
const selectedPack = packs.find((pack) => pack.id === selectedPackId) ?? packs[0];
|
||||
const selectedSessionPack = selectedSession ? packs.find((pack) => pack.id === selectedSession.linkedSessionPackId) : selectedPack;
|
||||
const parsedResponse = responseText.trim() ? parseBrowserBridgeResponse(responseText) : selectedSession?.responseText ? parseBrowserBridgeResponse(selectedSession.responseText) : undefined;
|
||||
const awaitingFeedback = sessions.filter((session) => session.status === "response-pasted" && !session.linkedFeedbackId).length;
|
||||
const feedbackAlreadyCreated = Boolean(selectedSession?.linkedFeedbackId);
|
||||
|
||||
const prepareBridge = () => {
|
||||
const packId = selectedPack?.id ?? selectedPackId;
|
||||
if (!packId) return;
|
||||
const session = createBrowserBridgeSession(project.id, packId, target);
|
||||
if (session) {
|
||||
setSelectedSessionId(session.id);
|
||||
setResponseText("");
|
||||
showSuccess("Browser bridge prepared");
|
||||
}
|
||||
};
|
||||
|
||||
const copyPrompt = async () => {
|
||||
if (!selectedSession) return;
|
||||
const copied = await copyText(selectedSession.promptText, "Browser bridge prompt copied");
|
||||
if (copied) updateBrowserBridgeSessionStatus(project.id, selectedSession.id, "copied");
|
||||
};
|
||||
|
||||
const copyJson = async () => {
|
||||
if (!selectedSession) return;
|
||||
await copyText(selectedSession.payloadJson, "Browser bridge JSON copied");
|
||||
};
|
||||
|
||||
const copyInstructions = async () => {
|
||||
if (!selectedSession) return;
|
||||
const payload = readBridgePayload(selectedSession);
|
||||
if (!payload) {
|
||||
showError("Browser bridge payload could not be read. Prepare a new bridge session.");
|
||||
return;
|
||||
}
|
||||
await copyText(formatBridgeInstructionsMarkdown(payload), "Browser bridge instructions copied");
|
||||
};
|
||||
|
||||
const downloadInstructions = () => {
|
||||
if (!selectedSession) return;
|
||||
const payload = readBridgePayload(selectedSession);
|
||||
if (!payload) {
|
||||
showError("Browser bridge payload could not be read. Prepare a new bridge session.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
downloadTextFile(`${project.id}-${selectedSession.id}-browser-bridge.md`, formatBridgeInstructionsMarkdown(payload), "text/markdown;charset=utf-8");
|
||||
showSuccess("Browser bridge instructions downloaded");
|
||||
} catch {
|
||||
showError("Browser bridge instructions could not be downloaded.");
|
||||
}
|
||||
};
|
||||
|
||||
const openTarget = () => {
|
||||
if (!selectedSession) return;
|
||||
const url = getBrowserBridgeTargetUrl(selectedSession.target);
|
||||
updateBrowserBridgeSessionStatus(project.id, selectedSession.id, "opened");
|
||||
if (!url) {
|
||||
showSuccess("Manual browser target marked opened");
|
||||
return;
|
||||
}
|
||||
const opened = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (opened) showSuccess(`${getBrowserBridgeTargetLabel(selectedSession.target)} opened`);
|
||||
else showError(`${getBrowserBridgeTargetLabel(selectedSession.target)} could not be opened. Open it manually and continue with copy/paste.`);
|
||||
};
|
||||
|
||||
const pasteResponse = () => {
|
||||
if (!selectedSession || !responseText.trim()) return;
|
||||
if (selectedSession.linkedFeedbackId) {
|
||||
showError("Feedback already exists for this browser bridge session.");
|
||||
return;
|
||||
}
|
||||
const next = pasteBrowserBridgeResponse(project.id, selectedSession.id, responseText);
|
||||
if (next) {
|
||||
setSelectedSessionId(next.id);
|
||||
showSuccess("Browser response pasted");
|
||||
}
|
||||
};
|
||||
|
||||
const createFeedback = () => {
|
||||
if (!selectedSession) return;
|
||||
if (selectedSession.linkedFeedbackId) {
|
||||
navigate(`/projects/${project.id}/execution-feedback`);
|
||||
return;
|
||||
}
|
||||
const feedbackItem = createExecutionFeedbackFromBridge(project.id, selectedSession.id, responseText);
|
||||
if (feedbackItem) {
|
||||
setResponseText("");
|
||||
showSuccess("Execution feedback draft created from browser bridge");
|
||||
navigate(`/projects/${project.id}/execution-feedback`);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveSession = () => {
|
||||
if (!selectedSession) return;
|
||||
updateBrowserBridgeSessionStatus(project.id, selectedSession.id, "archived");
|
||||
setSelectedSessionId("");
|
||||
showSuccess("Browser bridge session archived");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Browser Bridge"
|
||||
subtitle="No API cost browser companion for ChatGPT/Codex: prepare a controlled session-pack prompt, run it in your own browser, paste the response back, then create review-gated execution feedback."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/session-packs`)}>
|
||||
<PackageCheck size={14} className="mr-2" />Session Packs
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>
|
||||
<MessageSquareText size={14} className="mr-2" />Execution Feedback
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
<Workflow size={14} className="mr-2" />Build Loop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className={`border-l-4 ${packs.length ? "border-l-primary bg-primary/5" : "border-l-amber-500 bg-amber-50/50"}`}>
|
||||
<CardContent className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={packs.length ? "secondary" : "outline"}>{packs.length ? "Ready to prepare" : "Session pack required"}</Badge>
|
||||
<Badge variant="outline">{packs.length} pack(s)</Badge>
|
||||
<Badge variant="outline">{sessions.length} bridge session(s)</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-semibold">
|
||||
{packs.length ? "Prepare a browser bridge from the selected session pack." : "Create or open a Session Pack before using Browser Bridge."}
|
||||
</p>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
{packs.length
|
||||
? "Copy a controlled prompt, run it manually in your browser, then paste the response back as review-gated execution evidence."
|
||||
: "Browser Bridge does not start from a blank prompt. It needs a controlled pack so scope, validation and feedback template travel together."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{packs.length ? (
|
||||
<Button size="sm" onClick={prepareBridge}>
|
||||
<Sparkles size={14} className="mr-2" />Prepare bridge
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" onClick={() => navigate(`/projects/${project.id}/session-packs`)}>
|
||||
<PackageCheck size={14} className="mr-2" />Open Session Packs
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/handoffs`)}>
|
||||
Open Handoffs
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
<Workflow size={14} className="mr-2" />Build Loop
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BuildLoopActionRail
|
||||
projectId={project.id}
|
||||
activeStep="session-pack"
|
||||
handoffCount={(project.codexHandoffs ?? []).filter((handoff) => !handoff.archived).length}
|
||||
sessionPackCount={packs.length}
|
||||
executionCount={sessions.length}
|
||||
feedbackCount={feedback.length}
|
||||
reviewCount={awaitingFeedback}
|
||||
title="Manual browser execution stays review-gated"
|
||||
description="The bridge prepares prompts and parses pasted responses. It does not call AI APIs, scrape browser sessions or accept evidence automatically."
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-500 bg-emerald-50/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Safety boundary</CardTitle>
|
||||
<CardDescription>No API cost, visible user control, no hidden automation, and no accepted truth without review.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-4">
|
||||
{browserBridgeSafetyCopy.map((item) => (
|
||||
<div key={item} className="rounded-lg border bg-background p-3 text-sm">{item}</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-primary"><CardHeader className="pb-2"><CardDescription>Prepared sessions</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{sessions.length}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Awaiting feedback</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{awaitingFeedback}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Session packs</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{packs.length}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Feedback records</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{feedback.length}</CardContent></Card>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<aside className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prepare bridge</CardTitle>
|
||||
<CardDescription>Select a session pack and browser target.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{packs.length ? (
|
||||
<>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Session pack</span>
|
||||
<select value={selectedPack?.id ?? selectedPackId} onChange={(event) => setSelectedPackId(event.target.value)} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||
{packs.map((pack) => <option key={pack.id} value={pack.id}>{pack.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Target</span>
|
||||
<select value={target} onChange={(event) => setTarget(event.target.value as BrowserBridgeTarget)} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||
<option value="chatgpt">ChatGPT</option>
|
||||
<option value="codex-web">Codex web</option>
|
||||
<option value="manual">Manual browser</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button className="w-full justify-start" onClick={prepareBridge}>
|
||||
<Sparkles size={14} className="mr-2" />Prepare browser bridge
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-dashed bg-muted/20 p-4 text-sm">
|
||||
<p className="font-semibold">No session pack is ready yet</p>
|
||||
<p className="text-muted-foreground">Browser Bridge starts from a controlled Session Pack so the browser prompt has scope, validation and a feedback template.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/handoffs`)}>
|
||||
Open Handoffs
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/session-packs`)}>
|
||||
Open Session Packs
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
Open Build Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bridge sessions</CardTitle>
|
||||
<CardDescription>{sessions.length} active browser session(s).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{sessions.length ? sessions.map((session) => (
|
||||
<button key={session.id} type="button" onClick={() => {
|
||||
setSelectedSessionId(session.id);
|
||||
setResponseText(session.responseText ?? "");
|
||||
}} className={`w-full rounded-lg border p-3 text-left transition hover:border-primary/50 ${selectedSession?.id === session.id ? "border-primary" : "bg-background"}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-semibold">{session.title}</p>
|
||||
<Badge variant={statusVariant(session.status)}>{session.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{getBrowserBridgeTargetLabel(session.target)} - {session.linkedSessionPackId}</p>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No bridge sessions yet. Prepare one from an existing session pack.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{selectedSession ? (
|
||||
<section className="space-y-4">
|
||||
<Card className="border-l-4 border-l-primary bg-primary/5">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedSession.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{getBrowserBridgeTargetLabel(selectedSession.target)} - {selectedSession.status} - linked to {selectedSessionPack?.title ?? selectedSession.linkedSessionPackId}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit" variant={statusVariant(selectedSession.status)}>{selectedSession.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={copyPrompt}><ClipboardCopy size={14} className="mr-2" />Copy browser prompt</Button>
|
||||
<Button size="sm" variant="outline" onClick={openTarget}><ExternalLink size={14} className="mr-2" />Open {getBrowserBridgeTargetLabel(selectedSession.target)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={copyJson}><FileJson2 size={14} className="mr-2" />Copy JSON</Button>
|
||||
<Button size="sm" variant="outline" onClick={copyInstructions}><ClipboardCopy size={14} className="mr-2" />Copy instructions</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadInstructions}>Download instructions</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Archive size={14} className="mr-2" />Archive
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this browser bridge session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The session will leave the active Browser Bridge list. Existing execution feedback remains available in Execution Feedback.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep session</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={archiveSession}>Archive session</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border bg-background p-4 text-xs">{selectedSession.promptText}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><MessageSquareText size={18} /> Paste browser response</CardTitle>
|
||||
<CardDescription>Paste the final ChatGPT/Codex browser response. ProjectBrain will parse it as draft execution evidence only.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={responseText}
|
||||
onChange={(event) => setResponseText(event.target.value)}
|
||||
placeholder="Paste the final browser response here, including changed files, validation output, warnings, unresolved issues and what was not changed..."
|
||||
className="min-h-[260px] font-mono text-xs"
|
||||
disabled={feedbackAlreadyCreated}
|
||||
/>
|
||||
{feedbackAlreadyCreated ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950">
|
||||
<p className="font-semibold">Feedback draft already exists</p>
|
||||
<p className="mt-1">Continue in Execution Feedback to review, accept or convert the browser response.</p>
|
||||
<Button size="sm" variant="outline" className="mt-3 bg-background" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>
|
||||
Open Execution Feedback
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{parsedResponse ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
|
||||
<p className="font-semibold">Parsed response preview</p>
|
||||
<p className="mt-1 text-muted-foreground">{parsedResponse.summary}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">suggested {parsedResponse.suggestedStatus}</Badge>
|
||||
<Badge variant={parsedResponse.warnings.length ? "default" : "secondary"}>{parsedResponse.warnings.length} warning signal(s)</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={feedbackAlreadyCreated || !responseText.trim()} onClick={pasteResponse}>Paste response into bridge</Button>
|
||||
<Button size="sm" variant="outline" disabled={!feedbackAlreadyCreated && !selectedSession.responseText?.trim() && !responseText.trim()} onClick={createFeedback}>
|
||||
<TerminalSquare size={14} className="mr-2" />{feedbackAlreadyCreated ? "Open feedback draft" : "Create feedback draft"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status timeline</CardTitle>
|
||||
<CardDescription>Auditable manual bridge state for this session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{selectedSession.statusTimeline.map((event, index) => (
|
||||
<div key={`${event.status}-${event.at}-${index}`} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant={statusVariant(event.status)}>{event.status}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{new Date(event.at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{event.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle>No bridge session selected</CardTitle>
|
||||
<CardDescription>Prepare a bridge session from a session pack to start the manual browser flow.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserBridge;
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bug as BugIcon, Plus, Search, Filter, AlertCircle, Layers, User } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
|
||||
const Bugs = () => {
|
||||
const { projectId } = useParams();
|
||||
const { projects } = useProject();
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
const allBugs = project?.featureGroups.flatMap(g => g.features).flatMap(f => f.bugs) ?? [];
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||||
|
||||
const handleReportBug = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
showSuccess("Bug reported successfully");
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Bugs"
|
||||
subtitle="Track and resolve regressions in your features."
|
||||
actions={
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2">
|
||||
<Plus size={16} />
|
||||
Report Bug
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleReportBug}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report New Bug</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provide details about the issue to help the team resolve it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Bug Title</Label>
|
||||
<Input id="title" placeholder="e.g. App crashes on logout" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="feature">Affected Feature</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select feature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{project?.featureGroups.flatMap(g => g.features).map(f => (
|
||||
<SelectItem key={f.id} value={f.id}>{f.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="priority">Severity</Label>
|
||||
<Select defaultValue="medium">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="desc">Steps to Reproduce</Label>
|
||||
<Textarea id="desc" placeholder="1. Go to... 2. Click on..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" variant="destructive">Report Bug</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search bugs..." className="pl-10" />
|
||||
</div>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Filter size={16} /> Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{allBugs.map((bug) => (
|
||||
<Card key={bug.id} className="hover:shadow-sm transition-shadow border-l-4 border-l-destructive">
|
||||
<CardContent className="p-6 flex items-center gap-6">
|
||||
<div className="p-3 rounded-full bg-destructive/10 text-destructive">
|
||||
<BugIcon size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-destructive uppercase tracking-tighter">{bug.id}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0",
|
||||
bug.priority === "Critical" ? "bg-destructive text-destructive-foreground" : "text-destructive border-destructive/20"
|
||||
)}
|
||||
>
|
||||
{bug.priority}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1 ml-2">
|
||||
<Layers size={12} /> {bug.feature}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{bug.title}</h3>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<User size={12} /> Reported by {bug.reporter}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<AlertCircle size={12} /> Status: {bug.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">View Details</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{allBugs.length === 0 && (
|
||||
<div className="text-center py-20 border-2 border-dashed rounded-xl bg-muted/10">
|
||||
<BugIcon size={48} className="mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No bugs reported</h3>
|
||||
<p className="text-muted-foreground">Your features are looking clean!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bugs;
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, GitBranch, ListChecks, ShieldCheck, Workflow, Wrench } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { getBuildLoopStatus } from "@/shared/lib/buildLoop";
|
||||
import { getFirstTimeBuildLoopSteps } from "@/shared/lib/buildLoopWalkthrough";
|
||||
import { getProjectReleaseReadiness } from "@/shared/lib/releaseReadiness";
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === "done") return "Gereed";
|
||||
if (status === "current") return "Actueel";
|
||||
if (status === "blocked") return "Geblokkeerd";
|
||||
return "Wacht";
|
||||
};
|
||||
|
||||
const BuildLoopDashboard = () => {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { findProjectById } = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
|
||||
if (!project || !projectId) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const status = getBuildLoopStatus(project);
|
||||
const walkthroughSteps = getFirstTimeBuildLoopSteps(project);
|
||||
const releaseReadiness = getProjectReleaseReadiness(project);
|
||||
const blockingGaps = status.repoGaps.filter((gap) => gap.severity === "blocking");
|
||||
const highRisks = status.risks.filter((risk) => risk.level === "high");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Uitvoeringscyclus · technische details"
|
||||
subtitle="Expertweergave van ProjectBrains interne route van context naar repositorywerk, validatie en bewijs. Voor normaal gebruik hoef je dit scherm niet te openen."
|
||||
actions={<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}><ArrowLeft size={14} />Terug naar opdracht</Button>}
|
||||
/>
|
||||
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-2"><Badge><Workflow size={13} />{status.phaseLabel}</Badge><Badge variant={status.recommendedAction.blocking ? "destructive" : "outline"}>{status.recommendedAction.blocking ? "Technisch geblokkeerd" : "Interne cyclus actief"}</Badge></div>
|
||||
<h2 className="mt-3 text-xl font-semibold">{status.recommendedAction.label}</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{status.recommendedAction.description}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Dit is een interne aanbeveling voor diagnose. De gebruikersgerichte volgende stap wordt centraal bepaald door de opdracht- en beslisstatus, niet door deze Build Loop.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(status.recommendedAction.route)}>Technische route openen <ArrowRight size={14} /></Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4" aria-label="Technische uitvoeringsstatus">
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Interne voortgang</p><p className="mt-1 text-2xl font-semibold">{status.progress}%</p><Progress value={status.progress} className="mt-2 h-2" /></CardContent></Card>
|
||||
<Card className={blockingGaps.length ? "border-destructive/40" : ""}><CardContent className="p-4"><p className="text-xs text-muted-foreground">Repositoryhiaten</p><p className="mt-1 text-2xl font-semibold">{status.repoGaps.length}</p><p className="text-xs text-muted-foreground">{blockingGaps.length} blokkerend</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Geslaagde controles</p><p className="mt-1 text-2xl font-semibold">{status.validationSummary.passed}</p><p className="text-xs text-muted-foreground">uit geregistreerd uitvoeringsbewijs</p></CardContent></Card>
|
||||
<Card className={highRisks.length ? "border-destructive/40" : ""}><CardContent className="p-4"><p className="text-xs text-muted-foreground">Hoog risico</p><p className="mt-1 text-2xl font-semibold">{highRisks.length}</p><p className="text-xs text-muted-foreground">{status.risks.length} technische signalen totaal</p></CardContent></Card>
|
||||
</section>
|
||||
|
||||
<details className="rounded-xl border bg-card" open={Boolean(status.recommendedAction.blocking)}>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl p-4 text-sm font-medium"><span className="flex items-center gap-2"><ListChecks size={17} />Interne fasen</span><Badge variant="outline">{walkthroughSteps.filter((step) => step.status === "done").length}/{walkthroughSteps.length} gereed</Badge></summary>
|
||||
<div className="grid gap-2 border-t p-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{walkthroughSteps.map((step, index) => <button key={step.id} type="button" onClick={() => navigate(step.route)} className={`rounded-lg border p-3 text-left transition hover:border-primary/50 ${step.status === "current" ? "border-primary bg-primary/5" : step.status === "done" ? "bg-emerald-50/50" : step.status === "blocked" ? "bg-muted/30" : "bg-background"}`}><div className="flex items-center justify-between gap-2"><span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-semibold">{index + 1}</span><Badge variant={step.status === "blocked" ? "destructive" : step.status === "done" ? "secondary" : "outline"}>{statusLabel(step.status)}</Badge></div><p className="mt-3 text-sm font-semibold">{step.label}</p><p className="mt-1 line-clamp-3 text-xs leading-5 text-muted-foreground">{step.what}</p></button>)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="rounded-xl border bg-card">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl p-4 text-sm font-medium"><span className="flex items-center gap-2"><GitBranch size={17} />Repository, bewijs en risico's</span><Badge variant={status.risks.length ? "secondary" : "outline"}>{status.risks.length} signalen</Badge></summary>
|
||||
<div className="grid gap-4 border-t p-4 lg:grid-cols-2">
|
||||
<Card><CardHeader><CardTitle className="text-base">Repositorytoestand</CardTitle><CardDescription>Interne status die ProjectBrain gebruikt bij veilige uitvoering.</CardDescription></CardHeader><CardContent className="space-y-3 text-sm"><p>{status.latestRepoState?.summary ?? "Nog geen afzonderlijke repositorymomentopname geregistreerd."}</p><div className="flex flex-wrap gap-2"><Badge variant="outline">{status.repoGaps.length} hiaten</Badge><Badge variant="outline">{status.skillCoverage.activeCount} uitvoeringsprofielen</Badge></div>{status.repoGaps.slice(0, 6).map((gap) => <div key={gap.id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-2"><p className="font-semibold">{gap.title}</p><Badge variant={gap.severity === "blocking" ? "destructive" : "outline"}>{gap.severity}</Badge></div><p className="mt-1 text-xs text-muted-foreground">{gap.description}</p></div>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Technische risico's</CardTitle><CardDescription>Deze signalen worden automatisch meegenomen in planning en validatie.</CardDescription></CardHeader><CardContent className="space-y-2">{status.risks.length ? status.risks.map((risk) => <button key={risk.id} type="button" onClick={() => navigate(risk.route)} className="w-full rounded-lg border p-3 text-left"><div className="flex items-center justify-between gap-2"><p className="text-sm font-semibold">{risk.title}</p><Badge variant={risk.level === "high" ? "destructive" : "outline"}>{risk.level}</Badge></div><p className="mt-1 text-xs text-muted-foreground">{risk.description}</p></button>) : <div className="flex items-start gap-2 rounded-lg border border-emerald-200 bg-emerald-50/50 p-3 text-sm"><CheckCircle2 size={16} className="mt-0.5 text-emerald-700" />Geen technisch Build Loop-risico gedetecteerd.</div>}</CardContent></Card>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="rounded-xl border bg-card">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl p-4 text-sm font-medium"><span className="flex items-center gap-2"><ShieldCheck size={17} />Releasegereedheid</span><Badge variant={releaseReadiness.verdict === "blocked" ? "destructive" : releaseReadiness.verdict === "watch" ? "secondary" : "outline"}>{releaseReadiness.score}%</Badge></summary>
|
||||
<div className="space-y-4 border-t p-4">
|
||||
<div className="rounded-lg border bg-muted/20 p-4"><p className="font-semibold">Alleen relevant wanneer de huidige opdracht daadwerkelijk release of deployment als doel heeft</p><p className="mt-1 text-sm text-muted-foreground">Releasegates bepalen niet langer de gewone volgende stap van een project. Ze blijven hier beschikbaar als specialistisch bewijs.</p></div>
|
||||
<div className="grid gap-2 md:grid-cols-2">{releaseReadiness.gates.map((gate) => <button key={gate.id} type="button" onClick={() => navigate(gate.route)} className="rounded-lg border p-3 text-left"><div className="flex items-center justify-between gap-2"><p className="text-sm font-semibold">{gate.label}</p><Badge variant={gate.status === "block" ? "destructive" : "outline"}>{gate.status}</Badge></div><p className="mt-1 text-xs text-muted-foreground">{gate.detail}</p></button>)}</div>
|
||||
{releaseReadiness.validationCommands.length ? <details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium">Validatiecommando's</summary><div className="space-y-1 border-t p-3">{releaseReadiness.validationCommands.map((command) => <code key={command} className="block rounded bg-muted px-2 py-1 text-xs">{command}</code>)}</div></details> : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="rounded-xl border bg-card">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl p-4 text-sm font-medium"><span className="flex items-center gap-2"><Wrench size={17} />Recente technische gebeurtenissen</span><Badge variant="outline">{status.recentEvents.length}</Badge></summary>
|
||||
<div className="grid gap-2 border-t p-4 md:grid-cols-2 xl:grid-cols-3">{status.recentEvents.map((event) => <button key={event.id} type="button" onClick={() => navigate(event.route)} className="rounded-lg border p-3 text-left"><p className="text-sm font-semibold">{event.title}</p><p className="mt-1 line-clamp-3 text-xs text-muted-foreground">{event.summary}</p><p className="mt-2 text-[11px] text-muted-foreground">{event.timestamp}</p></button>)}{!status.recentEvents.length ? <p className="text-sm text-muted-foreground">Geen recente technische gebeurtenissen.</p> : null}</div>
|
||||
</details>
|
||||
|
||||
{status.recommendedAction.blocking ? <div className="flex items-start gap-3 rounded-xl border border-amber-300 bg-amber-50/40 p-4 text-sm"><AlertTriangle size={17} className="mt-0.5 text-amber-700" /><div><p className="font-semibold">Technische blokkade gedetecteerd</p><p className="mt-1 text-muted-foreground">De gebruikersgerichte opdrachtcockpit bepaalt of dit werkelijk menselijke tussenkomst vereist. Een intern ontbrekend artefact is op zichzelf geen reden meer om de gebruiker te onderbreken.</p></div></div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuildLoopDashboard;
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Badge, Button } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { BrainCircuit, CalendarClock, CheckCircle2, FolderKanban, History, PackageOpen, Sparkles } from "lucide-react";
|
||||
import { createBuildJournalEntryMock, createRegressionFromBuildMock } from "@/shared/lib/projectbrainActions";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
|
||||
const TrustPanel = ({
|
||||
source,
|
||||
confidence,
|
||||
approvalState,
|
||||
lastChecked,
|
||||
}: {
|
||||
source: string;
|
||||
confidence: number;
|
||||
approvalState: string;
|
||||
lastChecked: string;
|
||||
}) => (
|
||||
<div className="text-xs text-muted-foreground space-y-1 border rounded-md p-3 bg-muted/20">
|
||||
<p>Source: <span className="text-foreground">{source}</span></p>
|
||||
<p>Confidence: <span className="text-foreground">{confidence}%</span></p>
|
||||
<p>Approval state: <span className="text-foreground">{approvalState}</span></p>
|
||||
<p>Last checked: <span className="text-foreground">{new Date(lastChecked).toLocaleString()}</span></p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BuildJournal = () => {
|
||||
const { projects, activeProjectId, builds, contextPacks } = useProject();
|
||||
const project = projects.find((item) => item.id === activeProjectId);
|
||||
const [importedBuilds, setImportedBuilds] = React.useState<Record<string, boolean>>({});
|
||||
const [extractedBuilds, setExtractedBuilds] = React.useState<Record<string, boolean>>({});
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={activeProjectId} />;
|
||||
|
||||
const activePack = contextPacks.find((pack) => pack.id === project.activeContextPackId);
|
||||
const visibleBuilds = builds.filter((build) => build.projectId === project.id);
|
||||
|
||||
const createBuildEntry = (build: { id: string; summary: string }) => {
|
||||
const result = createBuildJournalEntryMock({
|
||||
projectId: project.id,
|
||||
goal: `Audit action for ${build.id}`,
|
||||
targetTool: "agent-review",
|
||||
persona: "delivery-engineer",
|
||||
expectedOutput: `Manual entry generated from build action: ${build.summary}`,
|
||||
idempotencyKey: `build-entry-${project.id}-${build.id}`,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message || "Build journal action saved.");
|
||||
} else {
|
||||
showError(result.message || "Build journal action failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImported = (id: string, summary: string) => {
|
||||
setImportedBuilds((state) => ({ ...state, [id]: !state[id] }));
|
||||
createBuildEntry({ id, summary });
|
||||
};
|
||||
|
||||
const toggleExtracted = (id: string, summary: string) => {
|
||||
setExtractedBuilds((state) => ({ ...state, [id]: !state[id] }));
|
||||
createBuildEntry({ id, summary });
|
||||
};
|
||||
|
||||
const createManualBuildEntry = () => {
|
||||
const result = createBuildJournalEntryMock({
|
||||
projectId: project.id,
|
||||
goal: "Manual build summary import",
|
||||
targetTool: "build-orchestrator",
|
||||
persona: "delivery-engineer",
|
||||
expectedOutput: "Build summary imported via UI action",
|
||||
idempotencyKey: `build-entry-${project.id}-manual-summary`,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message || "Build summary created.");
|
||||
} else {
|
||||
showError(result.message || "Build summary creation failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const createRegressionMemory = (build: typeof visibleBuilds[number]) => {
|
||||
const result = createRegressionFromBuildMock({
|
||||
projectId: build.projectId,
|
||||
buildId: build.id,
|
||||
affectedFeatureIds: build.regressionsIntroduced.flatMap((entry) => entry.linkedFeatureIds),
|
||||
ruleTitle: `Generated from ${build.buildNumber}`,
|
||||
typicalCause: build.summary,
|
||||
idempotencyKey: `regression-build-${build.id}`,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message || "Regression memory created from build.");
|
||||
} else {
|
||||
showError(result.message || "Regression conversion failed.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Build Journal"
|
||||
subtitle="Central log for build-run previews, linked to context packs and checks."
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/projects/${project.id}/brainstorm?scope=research&mode=stress-test&title=${encodeURIComponent("Build and release risks")}&prompt=${encodeURIComponent("Brainstorm likely build, regression, validation, and release risks for the current project state.")}`}>
|
||||
<BrainCircuit size={14} className="mr-2" />
|
||||
Brainstorm build risks
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={createManualBuildEntry}>
|
||||
<PackageOpen size={14} className="mr-2" />
|
||||
Import build summary
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Builds</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold">{visibleBuilds.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Active context pack</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">{activePack?.title ?? "none"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Last run</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm flex items-center gap-2 text-muted-foreground">
|
||||
<CalendarClock size={14} />
|
||||
{visibleBuilds[0]?.createdAt ? new Date(visibleBuilds[0].createdAt).toLocaleString() : "No builds yet"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{visibleBuilds.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed rounded-xl text-muted-foreground">
|
||||
<p className="text-sm font-semibold">No build runs yet.</p>
|
||||
<p className="text-sm mt-2">Import or create a build summary to populate the build journal.</p>
|
||||
<div className="mt-3">
|
||||
<Button variant="outline" size="sm" onClick={createManualBuildEntry}>
|
||||
Import build summary
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{visibleBuilds.map((build) => (
|
||||
<Card key={build.id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{build.buildNumber}</CardTitle>
|
||||
<CardDescription>{build.goal}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={build.status === "success" ? "default" : build.status === "failed" ? "destructive" : "secondary"}>
|
||||
{build.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div className="text-sm space-y-1">
|
||||
<p>Agent: {build.agent}</p>
|
||||
<p>Target tool: {build.targetTool}</p>
|
||||
<p>Persona: {build.persona}</p>
|
||||
<p>Expected output: {build.expectedOutput}</p>
|
||||
<p>Goal: {build.goal}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Prompt/context pack</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{build.promptContextPackId ?? "none"}
|
||||
</p>
|
||||
{build.contextSelectionSummary ? (
|
||||
<div className="mt-2 rounded-md border bg-muted/20 p-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1 font-medium text-foreground">
|
||||
<Sparkles size={12} />
|
||||
Automatic build context
|
||||
</div>
|
||||
<p className="mt-1">{build.contextSelectionSummary}</p>
|
||||
<p className="mt-1">Selected: {build.selectedContextPackIds?.join(", ") || build.promptContextPackId || "none"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{build.promptContextPackId ? (
|
||||
<Link className="text-xs underline" to={`/projects/${project.id}/context-packs/${build.promptContextPackId}`}>
|
||||
open context pack
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-1">Files changed</p>
|
||||
<ul className="text-xs text-muted-foreground pl-4 list-disc">
|
||||
{build.filesChanged.map((file) => <li key={file}>{file}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-1">Regressions introduced</p>
|
||||
{build.regressionsIntroduced.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No regressions reported.</p>
|
||||
) : (
|
||||
<ul className="text-xs text-muted-foreground pl-4 list-disc">
|
||||
{build.regressionsIntroduced.map((item) => <li key={item.title}>{item.title}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrustPanel
|
||||
source={build.trust.source}
|
||||
confidence={build.trust.confidence}
|
||||
approvalState={build.trust.approvalState}
|
||||
lastChecked={build.trust.lastChecked}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleImported(build.id, build.summary)}
|
||||
>
|
||||
<CheckCircle2 size={14} className="mr-2" />
|
||||
{importedBuilds[build.id] ? "Build summary imported" : "Import build summary"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={extractedBuilds[build.id] ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleExtracted(build.id, build.summary)}
|
||||
>
|
||||
<History size={14} className="mr-2" />
|
||||
{extractedBuilds[build.id] ? "Lessons extracted" : "Extract lessons"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link to={`/projects/${project.id}/prompts?contextPackId=${build.promptContextPackId ?? ""}`}>Create follow-up prompt</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => createRegressionMemory(build)}
|
||||
>
|
||||
<FolderKanban size={14} className="mr-2" />
|
||||
Create regression memory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-1">Lessons</p>
|
||||
<ul className="text-xs text-muted-foreground pl-4 list-disc">
|
||||
{build.lessonsLearned.map((lesson) => <li key={lesson}>{lesson}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuildJournal;
|
||||
@@ -0,0 +1,777 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Workflow,
|
||||
ShieldAlert,
|
||||
Link2,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Brain,
|
||||
ChevronRight,
|
||||
Archive,
|
||||
Eye,
|
||||
Search,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { AISuggestion, CheckItem, ConfidenceLevel } from "@/shared/lib/model";
|
||||
import {
|
||||
addItemToContextPack,
|
||||
convertSuggestionToBugMock,
|
||||
convertSuggestionToMemoryRuleMock,
|
||||
convertSuggestionToTaskMock,
|
||||
markSuggestionReviewed,
|
||||
} from "@/shared/lib/projectbrainActions";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
|
||||
type ReviewAction =
|
||||
| "accept"
|
||||
| "reject"
|
||||
| "merge"
|
||||
| "convert-bug"
|
||||
| "convert-task"
|
||||
| "convert-rule"
|
||||
| "convert-decision";
|
||||
|
||||
type ReviewStatus = "open" | "accepted" | "rejected" | "merged" | "converted";
|
||||
|
||||
type ReviewSuggestion = {
|
||||
sourceId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category:
|
||||
| "confirmed features"
|
||||
| "new feature candidates"
|
||||
| "possible regressions"
|
||||
| "uncertain findings"
|
||||
| "missing evidence"
|
||||
| "missing verification"
|
||||
| "architecture conflicts";
|
||||
trust: {
|
||||
source: string;
|
||||
confidence: number;
|
||||
evidence: number;
|
||||
lastChecked: string;
|
||||
approvalState: "pending" | "approved" | "rejected" | "blocked";
|
||||
};
|
||||
route: string;
|
||||
linkedFeatureIds: string[];
|
||||
linkedEntities: Array<{ id: string; label: string; route: string }>;
|
||||
};
|
||||
|
||||
const actionLabel = {
|
||||
accept: "Accept",
|
||||
reject: "Reject",
|
||||
merge: "Merge",
|
||||
"convert-bug": "Convert to bug",
|
||||
"convert-task": "Convert to task",
|
||||
"convert-rule": "Convert to memory rule",
|
||||
"convert-decision": "Convert to decision",
|
||||
};
|
||||
|
||||
const categories = [
|
||||
"confirmed features",
|
||||
"new feature candidates",
|
||||
"possible regressions",
|
||||
"uncertain findings",
|
||||
"missing evidence",
|
||||
"missing verification",
|
||||
"architecture conflicts",
|
||||
];
|
||||
|
||||
type ReviewFilter = "all" | "pending" | "accepted" | "rejected" | "converted" | "merged";
|
||||
|
||||
const TrustBlock = ({
|
||||
source,
|
||||
confidence,
|
||||
evidence,
|
||||
lastChecked,
|
||||
approvalState,
|
||||
}: {
|
||||
source: string;
|
||||
confidence: number;
|
||||
evidence: number;
|
||||
lastChecked: string;
|
||||
approvalState: "pending" | "approved" | "rejected" | "blocked";
|
||||
}) => (
|
||||
<div className="rounded-md border p-3 bg-muted/20 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Source: <span className="text-foreground">{source}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Confidence: <span className="text-foreground">{confidence}%</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Evidence: <span className="text-foreground">{evidence}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Last checked: <span className="text-foreground">{new Date(lastChecked).toLocaleString()}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Approval: <span className="text-foreground">{approvalState}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConfidenceBadge = ({ value }: { value: ConfidenceLevel | number }) => (
|
||||
<Badge variant={value >= 80 ? "default" : value >= 50 ? "secondary" : "destructive"} className="text-xs">
|
||||
{value}% confidence
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const AIReview = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const {
|
||||
projects,
|
||||
checks,
|
||||
contextPacks,
|
||||
knowledge,
|
||||
evidence,
|
||||
importRuns,
|
||||
relationships,
|
||||
architectureModels,
|
||||
} = useProject();
|
||||
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const allFeatures = project?.featureGroups.flatMap((group) => group.features) ?? [];
|
||||
const featureById = new Map(allFeatures.map((feature) => [feature.id, feature]));
|
||||
|
||||
const [inclusions, setInclusions] = React.useState<Record<string, boolean>>({});
|
||||
const [reviewActions, setReviewActions] = React.useState<Record<string, ReviewStatus>>({});
|
||||
const [reviewFilter, setReviewFilter] = React.useState<ReviewFilter>("all");
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const activePack = project
|
||||
? contextPacks.find((pack) => pack.id === project.activeContextPackId) ??
|
||||
contextPacks.find((pack) => pack.projectId === project.id)
|
||||
: undefined;
|
||||
|
||||
const projectChecks = React.useMemo(() => (project ? checks.filter((check) => check.projectId === project.id) : []), [checks, project]);
|
||||
const projectEvidence = React.useMemo(
|
||||
() => (project ? evidence.filter((item) => item.route?.includes(`/projects/${project.id}/`)) : []),
|
||||
[evidence, project],
|
||||
);
|
||||
const projectKnowledge = React.useMemo(
|
||||
() => (project ? knowledge.filter((item) => item.projectId === project.id) : []),
|
||||
[knowledge, project],
|
||||
);
|
||||
const featureImport = React.useMemo(
|
||||
() => (project ? importRuns.find((run) => run.projectId === project.id && run.architectureFirstPipeline) : undefined),
|
||||
[importRuns, project],
|
||||
);
|
||||
const architecture = React.useMemo(
|
||||
() => (project ? architectureModels.find((model) => model.projectId === project.id) : undefined),
|
||||
[architectureModels, project],
|
||||
);
|
||||
|
||||
const pipeline = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
step: "Ingest",
|
||||
state: Boolean(featureImport),
|
||||
detail: featureImport?.ingressSummary ?? "No import run found",
|
||||
source: featureImport?.trust.source ?? "preview",
|
||||
},
|
||||
{
|
||||
step: "Architecture discovery",
|
||||
state: Boolean(architecture),
|
||||
detail: architecture?.summary ?? "No architecture model available",
|
||||
source: architecture?.trust.source ?? "preview",
|
||||
},
|
||||
{
|
||||
step: "Module inference",
|
||||
state: Boolean(featureImport?.findings.modulesInferred.length),
|
||||
detail:
|
||||
featureImport?.findings.modulesInferred.join(", ") || "No modules inferred",
|
||||
source: featureImport?.trust.source ?? "preview",
|
||||
},
|
||||
{
|
||||
step: "Feature detection",
|
||||
state: featureImport && featureImport.findings.featuresDetected.length > 0,
|
||||
detail: featureImport ? `Detected ${featureImport.findings.featuresDetected.length} features` : "No feature detection output",
|
||||
source: featureImport?.trust.source ?? "preview",
|
||||
},
|
||||
{
|
||||
step: "Risks & suggestions",
|
||||
state: Boolean(featureImport?.findings.risks.length),
|
||||
detail: featureImport
|
||||
? `${featureImport.findings.risks.length} risks • ${featureImport.findings.suggestions.length} suggestions`
|
||||
: "No risk/suggestion output",
|
||||
source: featureImport?.trust.source ?? "preview",
|
||||
},
|
||||
],
|
||||
[architecture, featureImport],
|
||||
);
|
||||
|
||||
const reviewPool = React.useMemo<ReviewSuggestion[]>(() => {
|
||||
if (!project) return [];
|
||||
|
||||
const mapCategory = (sourceCategory: CheckItem["category"] | string): ReviewSuggestion["category"] => {
|
||||
if (sourceCategory === "confirmed feature") return "confirmed features";
|
||||
if (sourceCategory === "new feature candidate") return "new feature candidates";
|
||||
if (sourceCategory === "possible regression") return "possible regressions";
|
||||
if (sourceCategory === "uncertain finding") return "uncertain findings";
|
||||
if (sourceCategory === "missing evidence") return "missing evidence";
|
||||
if (sourceCategory === "missing verification") return "missing verification";
|
||||
return "architecture conflicts";
|
||||
};
|
||||
|
||||
const checkItems = projectChecks.map((check) => ({
|
||||
sourceId: check.id,
|
||||
title: check.title,
|
||||
description: check.detail,
|
||||
category: mapCategory(check.category),
|
||||
trust: {
|
||||
source: check.trust.source,
|
||||
confidence: check.trust.confidence,
|
||||
evidence: check.trust.evidence.length,
|
||||
lastChecked: check.trust.lastChecked,
|
||||
approvalState: check.trust.approvalState,
|
||||
},
|
||||
route: `/projects/${project.id}/checks`,
|
||||
linkedFeatureIds: check.linkedFeatureIds ?? [],
|
||||
linkedEntities:
|
||||
check.linkedEntities?.map((entry) => ({ id: entry.id, label: entry.label, route: entry.link })) ?? [],
|
||||
}));
|
||||
|
||||
const suggestionItems = project.featureGroups.flatMap((group) =>
|
||||
group.features.flatMap((feature) =>
|
||||
feature.aiSuggestions.map((suggestion: AISuggestion) => {
|
||||
const category: ReviewSuggestion["category"] =
|
||||
suggestion.type === "New Feature"
|
||||
? "new feature candidates"
|
||||
: suggestion.type === "Regression"
|
||||
? "possible regressions"
|
||||
: suggestion.type === "Uncertain"
|
||||
? "uncertain findings"
|
||||
: "missing verification";
|
||||
|
||||
return {
|
||||
sourceId: `feature-${feature.id}-${suggestion.id}`,
|
||||
title: `${feature.name}: ${suggestion.title}`,
|
||||
description: suggestion.description,
|
||||
category,
|
||||
trust: {
|
||||
source: suggestion.trust?.source ?? "AI suggestion stream",
|
||||
confidence: suggestion.trust?.confidence ?? suggestion.confidence,
|
||||
evidence: suggestion.trust?.evidence.length ?? 0,
|
||||
lastChecked: suggestion.trust?.lastChecked ?? new Date().toISOString(),
|
||||
approvalState: suggestion.trust?.approvalState ?? "pending",
|
||||
},
|
||||
route: `/projects/${project.id}/features/${feature.id}`,
|
||||
linkedFeatureIds: [feature.id],
|
||||
linkedEntities: [{ id: feature.id, label: feature.name, route: `/projects/${project.id}/features/${feature.id}` }],
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return [...checkItems, ...suggestionItems];
|
||||
}, [project, projectChecks]);
|
||||
|
||||
const groupedReview = React.useMemo(() => {
|
||||
const grouped = new Map<string, ReviewSuggestion[]>(categories.map((item) => [item, []]));
|
||||
reviewPool.forEach((item) => {
|
||||
grouped.get(item.category)?.push(item);
|
||||
});
|
||||
return grouped;
|
||||
}, [reviewPool]);
|
||||
|
||||
const filteredReview = React.useMemo(
|
||||
() =>
|
||||
reviewPool.filter((item) => {
|
||||
const status = reviewActions[item.sourceId];
|
||||
if (reviewFilter === "all") return true;
|
||||
if (reviewFilter === "pending") return !status;
|
||||
if (reviewFilter === "converted") return status === "converted";
|
||||
return status === reviewFilter;
|
||||
}).filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
[query, reviewFilter, reviewActions, reviewPool],
|
||||
);
|
||||
|
||||
const relationshipCount = project
|
||||
? relationships.filter((relationship) => relationship.from === project.id || relationship.to === project.id).length +
|
||||
relationships.filter(
|
||||
(relationship) => allFeatures.some((feature) => relationship.from === feature.id || relationship.to === feature.id),
|
||||
).length
|
||||
: 0;
|
||||
|
||||
const packEvidenceIds = new Set(activePack?.evidence.map((entry) => entry.refId) ?? []);
|
||||
const isInPack = (id: string) => packEvidenceIds.has(id) || inclusions[id] || false;
|
||||
|
||||
const getFeatureLinks = (featureId: string) =>
|
||||
projectEvidence.filter((entry) => entry.linkedEntity.type === "feature" && entry.linkedEntity.id === featureId);
|
||||
|
||||
const handleReviewAction = (id: string, action: ReviewAction) => {
|
||||
if (!project) return;
|
||||
const actionRequest = {
|
||||
projectId: project.id,
|
||||
sourceId: id,
|
||||
idempotencyKey: `review-${project.id}-${id}`,
|
||||
};
|
||||
|
||||
let result;
|
||||
if (action === "accept") {
|
||||
result = markSuggestionReviewed({ ...actionRequest, reviewStatus: "accept" });
|
||||
} else if (action === "reject") {
|
||||
result = markSuggestionReviewed({ ...actionRequest, reviewStatus: "reject" });
|
||||
} else if (action === "merge") {
|
||||
result = markSuggestionReviewed({ ...actionRequest, reviewStatus: "merge" });
|
||||
} else if (action === "convert-bug") {
|
||||
result = convertSuggestionToBugMock(actionRequest);
|
||||
} else if (action === "convert-task") {
|
||||
result = convertSuggestionToTaskMock(actionRequest);
|
||||
} else {
|
||||
result = convertSuggestionToMemoryRuleMock(actionRequest);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
setReviewActions((state) => ({
|
||||
...state,
|
||||
[id]:
|
||||
action === "accept"
|
||||
? "accepted"
|
||||
: action === "reject"
|
||||
? "rejected"
|
||||
: action === "merge"
|
||||
? "merged"
|
||||
: "converted",
|
||||
}));
|
||||
showSuccess(result.message || "Review state updated.");
|
||||
} else {
|
||||
showError(result?.message || "Review action failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const addSourceToContextPack = (id: string, title: string) => {
|
||||
if (!project || !activePack) return;
|
||||
const featureMatch = /^feature-(.+?)-/.exec(id);
|
||||
const itemId = featureMatch?.[1] ? `feature-${featureMatch[1]}` : id;
|
||||
const result = addItemToContextPack({
|
||||
projectId: project.id,
|
||||
contextPackId: activePack.id,
|
||||
itemType: "feature",
|
||||
itemId,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setInclusions((state) => ({ ...state, [id]: !state[id] }));
|
||||
showSuccess(`${title} added to active context pack via action.`);
|
||||
} else {
|
||||
showError(result.message || "Unable to add source to context pack.");
|
||||
}
|
||||
};
|
||||
|
||||
const addKnowledgeToContextPack = (id: string, title: string) => {
|
||||
if (!project || !activePack) return;
|
||||
const result = addItemToContextPack({
|
||||
projectId: project.id,
|
||||
contextPackId: activePack.id,
|
||||
itemType: "memoryRule",
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`${title} included into active context pack.`);
|
||||
} else {
|
||||
showError(result.message || "Unable to include knowledge in context pack.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Checks & Intelligence Review</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Architecture-first analysis: ingest → architecture discovery → module inference → feature detection → risks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Layers size={16} /> AI pipeline context
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-5">
|
||||
{pipeline.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className={`rounded-md border p-3 ${step.state ? "bg-emerald-500/5 border-emerald-200/40" : "bg-muted/40"}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">{step.step}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{step.detail}</p>
|
||||
<p className="text-[11px] mt-2 text-muted-foreground">Source: {step.source}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3 text-sm">
|
||||
<Card className="p-3">
|
||||
<p className="text-muted-foreground">Import evidence</p>
|
||||
<p className="text-sm font-semibold">{featureImport ? "completed" : "missing"}</p>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<p className="text-muted-foreground">Active context pack</p>
|
||||
<p className="text-sm font-semibold">{activePack?.title ?? "none selected"}</p>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<p className="text-muted-foreground">Relationship pointers</p>
|
||||
<p className="text-sm font-semibold">{relationshipCount}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="review-board" className="w-full">
|
||||
<TabsList className="grid w-full md:grid-cols-4">
|
||||
<TabsTrigger value="review-board">Review Board</TabsTrigger>
|
||||
<TabsTrigger value="checks">Checks</TabsTrigger>
|
||||
<TabsTrigger value="evidence">Evidence</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">Knowledge</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="review-board" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Workflow size={16} /> Review Board
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Every finding now supports review actions and context-pack inclusion feedback.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between pb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search suggestions..." className="pl-9" />
|
||||
</div>
|
||||
<Select value={reviewFilter} onValueChange={(value: ReviewFilter) => setReviewFilter(value)}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="Filter status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="accepted">Accepted</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="merged">Merged</SelectItem>
|
||||
<SelectItem value="converted">Converted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{categories.map((category) => {
|
||||
const items = groupedReview.get(category) ?? [];
|
||||
const visible = filteredReview.filter((item) => item.category === category);
|
||||
if (visible.length === 0) return null;
|
||||
return (
|
||||
<div key={category} className="space-y-3">
|
||||
<h3 className="text-xs uppercase tracking-wide text-muted-foreground">{category}</h3>
|
||||
<div className="grid gap-3">
|
||||
{visible.map((item) => {
|
||||
const status = reviewActions[item.sourceId] ?? "open";
|
||||
return (
|
||||
<Card key={item.sourceId} className="p-4">
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<ConfidenceBadge value={item.trust.confidence} />
|
||||
<Badge>{status}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{item.linkedEntities.map((entry) => (
|
||||
<Link
|
||||
key={entry.id}
|
||||
to={entry.route}
|
||||
className="inline-flex text-xs underline underline-offset-4"
|
||||
>
|
||||
{entry.label}
|
||||
<ChevronRight size={10} className="ml-1" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(item.route)}>
|
||||
Open source
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => addSourceToContextPack(item.sourceId, item.title)}>
|
||||
{isInPack(item.sourceId) ? "Already in active pack" : "Include in context pack"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrustBlock {...item.trust} evidence={item.trust.evidence} />
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(
|
||||
["accept", "reject", "merge", "convert-bug", "convert-task", "convert-rule", "convert-decision"] as ReviewAction[]
|
||||
).map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
size="sm"
|
||||
variant={action === "reject" ? "destructive" : "outline"}
|
||||
onClick={() => handleReviewAction(item.sourceId, action)}
|
||||
>
|
||||
{actionLabel[action]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Evidence linked: {getFeatureLinks(item.linkedFeatureIds[0] ?? "").length}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredReview.length === 0 && <p className="text-sm text-muted-foreground">No review items found for this filter.</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checks" className="space-y-4">
|
||||
{projectChecks.length === 0 && <p className="text-sm text-muted-foreground">No checks for this project.</p>}
|
||||
{projectChecks.map((check) => (
|
||||
<Card key={check.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<CardTitle className="text-lg">{check.title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{check.status}</Badge>
|
||||
<Badge variant="secondary">{check.pipelineStage.replace("_", " ")}</Badge>
|
||||
<Badge>{check.category}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>Severity: {check.severity}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{check.detail}</p>
|
||||
<TrustBlock
|
||||
source={check.trust.source}
|
||||
confidence={check.trust.confidence}
|
||||
evidence={check.trust.evidence.length}
|
||||
lastChecked={check.trust.lastChecked}
|
||||
approvalState={check.trust.approvalState}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/projects/${project.id}/insights`)}
|
||||
>
|
||||
<GitBranch size={14} className="mr-2" />
|
||||
Architecture context
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/projects/${project.id}/relationships`)}
|
||||
>
|
||||
<Link2 size={14} className="mr-2" />
|
||||
Relationships
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReviewAction(check.id, "accept")}
|
||||
>
|
||||
<CheckCircle2 size={14} className="mr-2" /> Mark reviewed
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="evidence" className="space-y-4">
|
||||
{projectEvidence.length === 0 && <p className="text-sm text-muted-foreground">No evidence artifacts found.</p>}
|
||||
{projectEvidence.map((entry) => (
|
||||
<Card key={entry.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{entry.linkedEntity.label}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{entry.type}</Badge>
|
||||
<Badge>{entry.linkedEntity.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>Linked to: {entry.linkedEntity.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{entry.reason}</p>
|
||||
<TrustBlock
|
||||
source={entry.source}
|
||||
confidence={entry.confidence}
|
||||
evidence={entry.trustEvidence?.length ?? 0}
|
||||
lastChecked={entry.lastSeen}
|
||||
approvalState={entry.approvalState}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entry.route && (
|
||||
<Link className="text-xs underline" to={entry.route}>
|
||||
<Archive size={14} className="inline-block mr-1" />
|
||||
Open target
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addSourceToContextPack(entry.id, entry.linkedEntity.label)}
|
||||
>
|
||||
{isInPack(entry.id) ? "Already in active pack" : "Include in context pack"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="knowledge" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Knowledge Vault</CardTitle>
|
||||
<CardDescription>Central product knowledge connected to features and context packs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid md:grid-cols-2 gap-3">
|
||||
{projectKnowledge.length === 0 && <p className="text-sm text-muted-foreground">No knowledge entries yet.</p>}
|
||||
{projectKnowledge.map((item) => (
|
||||
<Card key={item.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold">{item.title}</p>
|
||||
<Badge variant="secondary">{item.category}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.summary}</p>
|
||||
<TrustBlock
|
||||
source={item.trust?.source ?? "preview"}
|
||||
confidence={item.trust?.confidence ?? 70}
|
||||
evidence={item.trust?.evidence.length ?? 0}
|
||||
lastChecked={item.trust?.lastChecked ?? new Date().toISOString()}
|
||||
approvalState={item.trust?.approvalState ?? "pending"}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
{(item.relatedFeatureIds ?? []).map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/projects/${project.id}/features/${id}`}
|
||||
className="text-xs underline underline-offset-4"
|
||||
>
|
||||
{featureById.get(id)?.name ?? id}
|
||||
</Link>
|
||||
))}
|
||||
{(item.relatedContextPackIds ?? []).map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/projects/${project.id}/context-packs`}
|
||||
className="text-xs underline underline-offset-4"
|
||||
>
|
||||
pack:{id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={item.includeInContextPacks?.length ? "secondary" : "outline"}
|
||||
onClick={() => addKnowledgeToContextPack(item.id, item.title)}
|
||||
>
|
||||
<Brain size={14} className="mr-2" />
|
||||
{item.includeInContextPacks?.length ? "Already in active context pack" : "Include in context pack"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Knowledge-linked items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
{projectKnowledge.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No linked items.</p>
|
||||
) : (
|
||||
projectKnowledge.flatMap((item) => [
|
||||
...item.relatedFeatureIds?.map((featureId) => (
|
||||
<Card key={`${item.id}-${featureId}`} className="p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Knowledge "{item.title}" linked to feature:{" "}
|
||||
<Link className="underline underline-offset-4" to={`/projects/${project.id}/features/${featureId}`}>
|
||||
{featureById.get(featureId)?.name ?? featureId}
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
)) ?? [],
|
||||
])
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ShieldAlert size={16} /> Intelligence to action
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
From checks and evidence to relationships, snapshots, and context packs.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activePack ? `Active context pack: ${activePack.title}` : "No active context pack selected."}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Checks reviewed: {Object.values(reviewActions).length} • Feature links: {allFeatures.length}
|
||||
</p>
|
||||
<Button className="w-fit" onClick={() => navigate(`/projects/${project.id}/relationships`)} variant="outline">
|
||||
<ArrowRight size={14} className="mr-2" />
|
||||
Open Relationship Graph
|
||||
</Button>
|
||||
<Button className="w-fit" variant="outline" onClick={() => navigate(`/projects/${project.id}/insights`)}>
|
||||
<Eye size={14} className="mr-2" />
|
||||
Open Insights
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIReview;
|
||||
@@ -0,0 +1,578 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import {
|
||||
ArrowRight,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import type { ContextPack, EvidenceItem, Feature, KnowledgeItem } from "@/shared/lib/model";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { useProjectContextPackSelectionData, useProjectContextPacksData } from "@/shared/hooks/useProjectData";
|
||||
import type { ContextPackSelectionMode } from "@/shared/lib/contextPackSelector";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
|
||||
type PackActionMode = "all" | "workspace" | "feature";
|
||||
|
||||
type TrustPayload = {
|
||||
source: string;
|
||||
confidence: number;
|
||||
lastChecked: string;
|
||||
approvalState: string;
|
||||
evidenceCount: number;
|
||||
};
|
||||
|
||||
const TrustPanel = ({ trust, title }: { trust: TrustPayload; title: string }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
<CardDescription>Evidence-linked trust metadata</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Source: <span className="text-foreground">{trust.source}</span></p>
|
||||
<p>Confidence: <span className="text-foreground">{trust.confidence}%</span></p>
|
||||
<p>Evidence refs: <span className="text-foreground">{trust.evidenceCount}</span></p>
|
||||
<p>Last checked: <span className="text-foreground">{new Date(trust.lastChecked).toLocaleString()}</span></p>
|
||||
<p>Approval: <span className="text-foreground">{trust.approvalState}</span></p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const BulletList = ({ title, items, emptyLabel = "No entries." }: { title: string; items: string[]; emptyLabel?: string }) => (
|
||||
<Card className="p-3">
|
||||
<p className="text-sm font-semibold mb-2">{title}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="text-xs text-muted-foreground pl-4 list-disc space-y-1">
|
||||
{items.map((entry) => <li key={`${title}-${entry}`}>{entry}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const ContextPacks = () => {
|
||||
const { projectId, packId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
projects,
|
||||
activeProjectId,
|
||||
contextPacks,
|
||||
checks,
|
||||
knowledge,
|
||||
relationships,
|
||||
evidence,
|
||||
setActiveContextPack,
|
||||
} = useProject();
|
||||
const isRealMode = projectBrainClientMode === "real";
|
||||
|
||||
const project = projects.find((item) => item.id === projectId) ||
|
||||
projects.find((item) => item.id === activeProjectId);
|
||||
const activeMode = (searchParams.get("source") as PackActionMode) || "all";
|
||||
const featureId = searchParams.get("featureId");
|
||||
const [selectionMode, setSelectionMode] = React.useState<ContextPackSelectionMode>("codex");
|
||||
const [selectionIntent, setSelectionIntent] = React.useState(
|
||||
"Continue the next build pass without breaking accepted truth, runtime persistence or validation gates.",
|
||||
);
|
||||
const realContextPackState = useProjectContextPacksData(project?.id);
|
||||
const selectionRequest = React.useMemo(
|
||||
() => ({
|
||||
mode: selectionMode,
|
||||
intent: selectionIntent,
|
||||
...(featureId ? { targetType: "feature" as const, targetId: featureId } : { targetType: "project" as const }),
|
||||
maxPacks: 3,
|
||||
}),
|
||||
[featureId, selectionIntent, selectionMode],
|
||||
);
|
||||
const autoSelectionState = useProjectContextPackSelectionData(
|
||||
project?.id,
|
||||
selectionRequest,
|
||||
project?.activeContextPackId,
|
||||
);
|
||||
const autoSelection = autoSelectionState.data;
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const dataSourceCard = (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Context pack data source</CardTitle>
|
||||
<CardDescription>
|
||||
{isRealMode
|
||||
? "Real backend mode is enabled. Context packs are loaded through the Fastify R4 read endpoint."
|
||||
: "Local data mode is enabled. Context packs are loaded from the local ProjectBrain fixture store."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant={isRealMode ? "default" : "secondary"}>{isRealMode ? "Live data" : "Local data"}</Badge>
|
||||
{isRealMode && realContextPackState.isLoading ? <Badge variant="outline">Loading packs</Badge> : null}
|
||||
{isRealMode && realContextPackState.error ? (
|
||||
<Badge variant="destructive">{realContextPackState.error.message}</Badge>
|
||||
) : null}
|
||||
{isRealMode ? (
|
||||
<Button size="sm" variant="outline" onClick={realContextPackState.reload}>
|
||||
Reload packs
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const allFeatures: Feature[] = project.featureGroups.flatMap((group) => group.features);
|
||||
const featureById = new Map(allFeatures.map((feature) => [feature.id, feature]));
|
||||
|
||||
const projectPacks = isRealMode
|
||||
? realContextPackState.data ?? []
|
||||
: contextPacks.filter((pack) => pack.projectId === project.id);
|
||||
const activePack = projectPacks.find((pack) => pack.id === project.activeContextPackId) ?? projectPacks[0];
|
||||
const selectedPack = packId ? projectPacks.find((pack) => pack.id === packId) : undefined;
|
||||
|
||||
const targetPack = featureId
|
||||
? projectPacks.find((pack) => pack.featureIds?.includes(featureId)) ?? selectedPack ?? activePack
|
||||
: selectedPack ?? activePack;
|
||||
|
||||
|
||||
const getFeatureNames = (ids: string[] = []) =>
|
||||
ids.map((id) => featureById.get(id)?.name).filter(Boolean) as string[];
|
||||
|
||||
const getEvidenceForPack = (pack: ContextPack): EvidenceItem[] =>
|
||||
pack.evidence
|
||||
.map((ref) => {
|
||||
return evidence.find(
|
||||
(entry) =>
|
||||
entry.id === ref.id ||
|
||||
entry.linkedEntity.id === ref.refId ||
|
||||
entry.linkedEntity.label === ref.label,
|
||||
);
|
||||
})
|
||||
.filter(Boolean) as EvidenceItem[];
|
||||
|
||||
const getKnowledgeForPack = (pack: ContextPack): KnowledgeItem[] =>
|
||||
knowledge.filter(
|
||||
(item) =>
|
||||
item.projectId === project.id &&
|
||||
((item.relatedContextPackIds?.includes(pack.id)) || (item.includeInContextPacks?.includes(pack.id))),
|
||||
);
|
||||
|
||||
const getChecksForPack = (pack: ContextPack) =>
|
||||
checks.filter((check) => check.linkedContextPackIds?.includes(pack.id));
|
||||
|
||||
const getRelationshipsForPack = (pack: ContextPack) =>
|
||||
relationships.filter(
|
||||
(relationship) =>
|
||||
pack.featureIds?.includes(relationship.from) ||
|
||||
pack.featureIds?.includes(relationship.to) ||
|
||||
pack.bugIds?.includes(relationship.from) ||
|
||||
pack.bugIds?.includes(relationship.to) ||
|
||||
pack.taskIds?.includes(relationship.from) ||
|
||||
pack.taskIds?.includes(relationship.to),
|
||||
);
|
||||
|
||||
const isInActivePack = (candidateId: string) => activePack?.id === candidateId;
|
||||
const activeDecisions = activePack?.decisionIds ?? [];
|
||||
|
||||
const applyActivePack = (targetPackId: string) => {
|
||||
if (!project) return;
|
||||
setActiveContextPack(project.id, targetPackId);
|
||||
showSuccess("Active context pack updated.");
|
||||
};
|
||||
|
||||
const renderListCard = (
|
||||
items: string[],
|
||||
title: string,
|
||||
emptyLabel: string,
|
||||
) => (
|
||||
<Card className="p-3">
|
||||
<p className="font-semibold text-sm mb-2">{title}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="text-xs text-muted-foreground pl-4 list-disc space-y-1">
|
||||
{items.map((item) => <li key={`${title}-${item}`}>{item}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderPackCard = (pack: ContextPack) => {
|
||||
const inActivePack = isInActivePack(pack.id);
|
||||
const inclusionLabel = inActivePack ? "Included in active context pack" : "Not included in active context pack";
|
||||
|
||||
return (
|
||||
<Card key={pack.id} className={inActivePack ? "border-primary/60" : undefined}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles size={16} />
|
||||
{pack.title}
|
||||
</span>
|
||||
<Badge variant={inActivePack ? "default" : "secondary"}>{inActivePack ? "active" : pack.category}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{inclusionLabel}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{pack.summary}</p>
|
||||
<div className="grid gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">Vision:</span> {pack.dna.vision}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">Current focus:</span>{" "}
|
||||
{pack.currentFocus ? `${pack.currentFocus.title} (${pack.currentFocus.purpose})` : "project-wide"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">Features:</span>{" "}
|
||||
{(pack.featureIds ?? []).length}
|
||||
</div>
|
||||
</div>
|
||||
<TrustPanel
|
||||
title="Pack trust"
|
||||
trust={{
|
||||
source: pack.trust.source,
|
||||
confidence: pack.trust.confidence,
|
||||
lastChecked: pack.trust.lastChecked,
|
||||
approvalState: pack.trust.approvalState,
|
||||
evidenceCount: pack.trust.evidence.length,
|
||||
}}
|
||||
/>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/projects/${project.id}/context-packs/${pack.id}`}>Open pack</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
applyActivePack(pack.id);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} className="mr-1" />
|
||||
{inActivePack ? "Already in active context pack" : "Add to active context pack"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPackDetail = (pack: ContextPack) => {
|
||||
const packFeatures = getFeatureNames(pack.featureIds ?? []);
|
||||
const packBugs = (pack.bugIds ?? []).map((id) => id);
|
||||
const packTasks = (pack.taskIds ?? []).map((id) => id);
|
||||
const packDecisions = pack.decisionIds ?? [];
|
||||
const linkedEvidence = getEvidenceForPack(pack);
|
||||
const linkedKnowledge = getKnowledgeForPack(pack);
|
||||
const linkedChecks = getChecksForPack(pack);
|
||||
const linkedRelations = getRelationshipsForPack(pack);
|
||||
const inActivePack = activePack?.id === pack.id;
|
||||
const actionLabel = inActivePack ? "Already included in active context pack" : "Add to active context pack";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2"><Brain size={16} /> {pack.title}</span>
|
||||
<Badge>{pack.category}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>From source: {activeMode}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="p-3">
|
||||
<p className="font-semibold text-sm mb-2">Project DNA</p>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Vision: {pack.dna.vision}</p>
|
||||
<p>Goals: {pack.dna.goals.join(", ") || "none"}</p>
|
||||
<p>Non-goals: {pack.dna.nonGoals.join(", ") || "none"}</p>
|
||||
<p>Target audience: {pack.dna.targetAudience}</p>
|
||||
<p>Principles: {pack.dna.principles.join(", ") || "none"}</p>
|
||||
<p>Maturity: {pack.dna.maturityStage}</p>
|
||||
</div>
|
||||
<Button className="mt-3" variant="outline" size="sm" asChild>
|
||||
<Link to={`/projects/${project.id}/dna`}>Open DNA</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<p className="font-semibold text-sm mb-2">Context state</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Focus: {pack.currentFocus?.title ?? "project-wide"} ({pack.currentFocus?.purpose ?? "context"})
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active inclusion: {inActivePack ? "Yes" : "No"}</p>
|
||||
<TrustPanel
|
||||
title="Active source trust"
|
||||
trust={{
|
||||
source: pack.trust.source,
|
||||
confidence: pack.trust.confidence,
|
||||
evidenceCount: pack.trust.evidence.length,
|
||||
lastChecked: pack.trust.lastChecked,
|
||||
approvalState: pack.trust.approvalState,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant={inActivePack ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => applyActivePack(pack.id)}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{renderListCard(packFeatures, "Features", "No features linked")}
|
||||
{renderListCard([...packBugs, ...packTasks], "Bugs / Tasks", "No bugs/tasks linked")}
|
||||
{renderListCard([...packDecisions, ...activeDecisions], "Decisions / Rules", "No decision links")}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="p-3">
|
||||
<div className="font-semibold text-sm mb-2 flex items-center gap-2"><ClipboardList size={16} /> Evidence</div>
|
||||
{linkedEvidence.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No evidence linked.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{linkedEvidence.map((item) => (
|
||||
<div key={item.id} className="rounded-md border p-2 bg-muted/20 text-xs">
|
||||
<p className="font-semibold">{item.linkedEntity.label}</p>
|
||||
<p className="text-muted-foreground">Type: {item.type} - Confidence: {item.confidence}%</p>
|
||||
<p className="text-muted-foreground">Source: {item.source}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<p className="font-semibold text-sm mb-2">Knowledge</p>
|
||||
{linkedKnowledge.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No knowledge entries linked.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{linkedKnowledge.map((item) => (
|
||||
<div key={item.id} className="rounded-md border p-2 bg-muted/20 text-xs">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p className="text-muted-foreground">Category: {item.category}</p>
|
||||
<p className="text-muted-foreground">Source: {item.source}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<BulletList title="Architecture notes" items={pack.architectureNotes ?? []} emptyLabel="No architecture notes." />
|
||||
<BulletList
|
||||
title="Risk / Regression notes"
|
||||
items={[...(pack.risks ?? []), ...(pack.regressions ?? [])]}
|
||||
emptyLabel="No risks or regressions linked."
|
||||
/>
|
||||
<BulletList title="Checks" items={linkedChecks.map((check) => check.title)} emptyLabel="No linked checks." />
|
||||
</div>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="font-semibold text-sm mb-2 flex items-center gap-2"><TriangleAlert size={16} /> Relationship links</div>
|
||||
{linkedRelations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No direct relationship links found.</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{linkedRelations.map((item) => (
|
||||
<div key={item.id} className="text-xs text-muted-foreground border p-2 rounded-md bg-muted/20">
|
||||
<p>
|
||||
{`${item.from} -> ${item.to}`}
|
||||
</p>
|
||||
<p className="text-[11px]">Type: {item.type} - Confidence: {item.strength}</p>
|
||||
<p className="text-[11px]">Rationale: {item.rationale}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/projects/${project.id}/prompts?contextPackId=${pack.id}`}>
|
||||
Generate prompt from active context pack
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/projects/${project.id}/checks`}>
|
||||
<Lightbulb size={14} className="mr-1" /> Open checks for review
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/projects/${project.id}/builds`}>
|
||||
<CheckCircle2 size={14} className="mr-1" /> Create / import build summary
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/projects/${project.id}/snapshots`}>
|
||||
<Layers size={14} className="mr-1" /> Open snapshots
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const selectionControlCard = (
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Sparkles size={16} /> Automatic context-pack selection
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Deterministic selector used by normal workspace flow and AI/Codex handoff flow. It explains why packs were selected before any prompt is generated.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 md:grid-cols-[220px_1fr_auto]">
|
||||
<Select value={selectionMode} onValueChange={(value) => setSelectionMode(value as ContextPackSelectionMode)}>
|
||||
<SelectTrigger aria-label="Context pack selection mode">
|
||||
<SelectValue placeholder="Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="workspace">Workspace</SelectItem>
|
||||
<SelectItem value="ai">AI</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="documentation">Documentation</SelectItem>
|
||||
<SelectItem value="validation">Validation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={selectionIntent}
|
||||
onChange={(event) => setSelectionIntent(event.target.value)}
|
||||
placeholder="Describe the current work intent so ProjectBrain can choose the right packs."
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={autoSelectionState.reload}>
|
||||
Re-score
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{autoSelectionState.error ? (
|
||||
<p className="text-xs text-destructive">Selector failed: {autoSelectionState.error.message}</p>
|
||||
) : null}
|
||||
{autoSelection ? (
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_2fr]">
|
||||
<div className="rounded-md border bg-background/70 p-3 text-sm">
|
||||
<p className="font-semibold">{autoSelection.decisionSummary}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Selected packs: {autoSelection.selectedContextPackIds.join(", ") || "none"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Intent: {autoSelection.intent}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{autoSelection.candidates.slice(0, 3).map((candidate) => (
|
||||
<Card key={candidate.contextPackId} className={candidate.contextPackId === autoSelection.primaryContextPackId ? "border-primary/60" : undefined}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{candidate.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{candidate.category} | {candidate.generatedBy}</p>
|
||||
</div>
|
||||
<Badge variant={candidate.contextPackId === autoSelection.primaryContextPackId ? "default" : "secondary"}>{candidate.score}</Badge>
|
||||
</div>
|
||||
<ul className="text-[11px] text-muted-foreground list-disc pl-4 space-y-1">
|
||||
{candidate.reasons.slice(0, 2).map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
{candidate.warnings[0] ? <p className="text-[11px] text-amber-600">{candidate.warnings[0]}</p> : null}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/projects/${project.id}/context-packs/${candidate.contextPackId}`}>Open</Link>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => applyActivePack(candidate.contextPackId)}>
|
||||
Make active
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No selector result yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const modeLabel =
|
||||
activeMode === "feature" && featureId ? `for feature ${featureById.get(featureId)?.name ?? featureId}` : "workspace";
|
||||
const focusedPack = activePack ? activePack.id : project.activeContextPackId;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Context Packs"
|
||||
subtitle={`Context Packs are primary context; ${modeLabel} mode.`}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/projects/${project.id}/brainstorm?scope=documentation&mode=structure&title=${encodeURIComponent("Context pack strategy")}&prompt=${encodeURIComponent("Brainstorm which reusable context packs, assumptions, risks, and signals this project needs next.")}`)}>
|
||||
<Brain size={14} className="mr-2" /> Brainstorm context
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={focusedPack ? `/projects/${project.id}/context-packs/${focusedPack}` : `/projects/${project.id}/context-packs`}>
|
||||
Active pack
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{dataSourceCard}
|
||||
{selectionControlCard}
|
||||
|
||||
{targetPack && !selectedPack && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing context pack generated from {modeLabel}.{" "}
|
||||
<Link to={`/projects/${project.id}/context-packs`} className="underline underline-offset-4">Back to all packs</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedPack ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Link to={`/projects/${project.id}/context-packs`} className="underline underline-offset-4">Back to all packs</Link>
|
||||
</p>
|
||||
{renderPackDetail(selectedPack)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{projectPacks.map((pack) => renderPackCard(pack))}
|
||||
{projectPacks.length === 0 && <p className="text-sm text-muted-foreground">No context packs available.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextPacks;
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
|
||||
const Decisions = () => {
|
||||
const { projectId } = useParams();
|
||||
return <Navigate to={projectId ? `/projects/${projectId}/knowledge?view=decisions` : "/projects"} replace />;
|
||||
};
|
||||
|
||||
export default Decisions;
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, ExternalLink, File, FileCode2, Folder, GitBranch, GitCommitHorizontal,
|
||||
GitMerge, GitPullRequest, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2,
|
||||
} from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from "@/components/ui";
|
||||
import { giteaApi, type GiteaFile, type GiteaRepositoryLink, type GiteaWorkspace } from "@/shared/api/giteaApi";
|
||||
import { ApiError } from "@/shared/api/httpClient";
|
||||
import { realClient } from "@/shared/api/realClient";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import type { FeatureDelivery } from "@/shared/lib/featureDeliveryModel";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
import { getUserFacingError } from "@/shared/lib/userFacingError";
|
||||
|
||||
const branchSuggestion = (projectId: string) => `projectbrain/${projectId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "project"}-${new Date().toISOString().slice(0, 16).replace(/[-T:]/g, "")}`;
|
||||
const parentPath = (path: string) => path.split("/").slice(0, -1).join("/");
|
||||
const isEditableBranch = (branch: string, defaultBranch?: string) => /^[a-z0-9][a-z0-9._/-]{0,299}$/i.test(branch)
|
||||
&& !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.endsWith(".lock") && branch !== defaultBranch;
|
||||
|
||||
const DevelopmentWorkbench = () => {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { projects } = useProject();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const resolvedProjectId = project?.id ?? projectId ?? "";
|
||||
const [workspace, setWorkspace] = React.useState<GiteaWorkspace>();
|
||||
const [repositories, setRepositories] = React.useState<GiteaRepositoryLink[]>([]);
|
||||
const [repositoryId, setRepositoryId] = React.useState("");
|
||||
const [deliveries, setDeliveries] = React.useState<FeatureDelivery[]>([]);
|
||||
const [deliveryId, setDeliveryId] = React.useState("");
|
||||
const [path, setPath] = React.useState("");
|
||||
const [ref, setRef] = React.useState("");
|
||||
const [selectedFile, setSelectedFile] = React.useState<GiteaFile>();
|
||||
const [editorContent, setEditorContent] = React.useState("");
|
||||
const [newFilePath, setNewFilePath] = React.useState("");
|
||||
const [branchName, setBranchName] = React.useState(() => branchSuggestion(resolvedProjectId));
|
||||
const [commitMessage, setCommitMessage] = React.useState("Update project files from ProjectBrain");
|
||||
const [pullRequestTitle, setPullRequestTitle] = React.useState(`ProjectBrain: continue ${project?.name ?? "project"}`);
|
||||
const [pullRequestBody, setPullRequestBody] = React.useState("Changes prepared and reviewed through the ProjectBrain Development Workbench.");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [actionBusy, setActionBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const loadWorkspace = React.useCallback(async (nextPath: string, nextRef: string, nextRepositoryId?: string) => {
|
||||
if (!resolvedProjectId) return;
|
||||
setLoading(true); setError(undefined);
|
||||
if (projectBrainClientMode !== "real") {
|
||||
setWorkspace(undefined);
|
||||
setRepositories([]);
|
||||
setDeliveries([]);
|
||||
setError("De live repositoryverkenner gebruikt Gitea. In lokale modus bereid je repositorystructuur en werkpakketten voor in Repositorybouwer.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [data, deliveryResponse, repositoryResponse] = await Promise.all([
|
||||
giteaApi.workspace(resolvedProjectId, nextPath, nextRef || undefined, nextRepositoryId),
|
||||
realClient.initiatives.listFeatureDeliveries(resolvedProjectId),
|
||||
giteaApi.projectRepositories(resolvedProjectId),
|
||||
]);
|
||||
setWorkspace(data); setPath(data.path); setRef(data.ref);
|
||||
setRepositories(repositoryResponse); setRepositoryId(data.link.id);
|
||||
setDeliveries(deliveryResponse.data);
|
||||
setDeliveryId((current) => {
|
||||
const matching = deliveryResponse.data.find((item) => item.spec.branch === data.ref && item.spec.repositoryId === data.link.id)?.id;
|
||||
const currentStillMatches = deliveryResponse.data.some((item) => item.id === current && (!item.spec.repositoryId || item.spec.repositoryId === data.link.id) && (!item.spec.branch || item.spec.branch === data.ref));
|
||||
return matching || (currentStillMatches ? current : "") || deliveryResponse.data.find((item) => item.spec.status === "approved")?.id || "";
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = projectBrainClientMode !== "real"
|
||||
? "De live repositorywerkbank is beschikbaar wanneer ProjectBrain met de backend is verbonden. In voorbeeldmodus worden geen echte Gitea-acties uitgevoerd."
|
||||
: cause instanceof ApiError && cause.code === "GITEA_LINK_NOT_FOUND"
|
||||
? "Dit project heeft nog geen gekoppelde Gitea-repository."
|
||||
: getUserFacingError(cause, "De live repository kon niet worden geladen.");
|
||||
setError(message);
|
||||
} finally { setLoading(false); }
|
||||
}, [resolvedProjectId]);
|
||||
|
||||
React.useEffect(() => { void loadWorkspace("", ""); }, [loadWorkspace]);
|
||||
|
||||
const selectedDelivery = deliveries.find((item) => item.id === deliveryId);
|
||||
const dirty = Boolean(selectedFile && editorContent !== selectedFile.content);
|
||||
const diffStats = React.useMemo(() => {
|
||||
if (!selectedFile) return undefined;
|
||||
const before = selectedFile.content.split("\n"); const after = editorContent.split("\n");
|
||||
let changed = 0;
|
||||
for (let index = 0; index < Math.max(before.length, after.length); index += 1) if (before[index] !== after[index]) changed += 1;
|
||||
return { before: before.length, after: after.length, changed };
|
||||
}, [editorContent, selectedFile]);
|
||||
React.useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (dirty) event.preventDefault(); };
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
React.useEffect(() => {
|
||||
if (!selectedDelivery || actionBusy || !["executing", "validating", "pull_request_open"].includes(selectedDelivery.spec.status)) return;
|
||||
const timer = window.setInterval(() => { void loadWorkspace(path, ref, repositoryId); }, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [actionBusy, loadWorkspace, path, ref, repositoryId, selectedDelivery]);
|
||||
React.useEffect(() => {
|
||||
setDeliveryId(""); setPath(""); setRef(""); setRepositoryId(""); setRepositories([]); setSelectedFile(undefined); setEditorContent(""); setNewFilePath("");
|
||||
setBranchName(branchSuggestion(resolvedProjectId));
|
||||
}, [resolvedProjectId]);
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const entries = [...(workspace?.entries ?? [])].sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
|
||||
const canWrite = workspace?.link.accessMode === "read_write";
|
||||
const livePullRequest = workspace?.pullRequests.find((item) => item.number === selectedDelivery?.spec.pullRequest?.number && item.head.ref === ref && item.state === "open" && !item.merged);
|
||||
const editable = !loading && !error && canWrite && isEditableBranch(ref, workspace?.link.defaultBranch) && selectedDelivery?.spec.branch === ref && (["executing", "validating"].includes(selectedDelivery.spec.status) || (selectedDelivery.spec.status === "pull_request_open" && Boolean(livePullRequest)));
|
||||
const activePullRequests = (workspace?.pullRequests ?? []).filter((pullRequest) => !ref || pullRequest.head.ref === ref);
|
||||
const availableDeliveries = deliveries.filter((item) =>
|
||||
["approved", "executing", "validating", "pull_request_open"].includes(item.spec.status)
|
||||
&& (!item.spec.repositoryId || item.spec.repositoryId === repositoryId));
|
||||
const discardAllowed = () => !dirty || window.confirm("Je hebt niet-gecommitte wijzigingen. Wil je ze verwerpen?");
|
||||
|
||||
const openPath = async (entry: GiteaWorkspace["entries"][number]) => {
|
||||
if (actionBusy || !discardAllowed()) return;
|
||||
if (entry.type === "dir") { setSelectedFile(undefined); setEditorContent(""); await loadWorkspace(entry.path, ref, repositoryId); return; }
|
||||
setActionBusy(true);
|
||||
try { const file = await giteaApi.file(resolvedProjectId, entry.path, ref, repositoryId); setSelectedFile(file); setEditorContent(file.content); setNewFilePath(""); }
|
||||
catch (cause) { showError(getUserFacingError(cause, "Het bestand kon niet worden geladen.")); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const changeRef = async (nextRef: string) => {
|
||||
if (!discardAllowed()) return;
|
||||
setSelectedFile(undefined); setEditorContent(""); setRef(nextRef); await loadWorkspace("", nextRef, repositoryId);
|
||||
};
|
||||
|
||||
const changeRepository = async (nextRepositoryId: string) => {
|
||||
if (!discardAllowed()) return;
|
||||
setSelectedFile(undefined); setEditorContent(""); setPath(""); setRef(""); setDeliveryId(""); setRepositoryId(nextRepositoryId);
|
||||
await loadWorkspace("", "", nextRepositoryId);
|
||||
};
|
||||
|
||||
const createBranch = async () => {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
if (!deliveryId) throw new Error("Selecteer eerst een goedgekeurd deliveryplan.");
|
||||
await giteaApi.createBranch(resolvedProjectId, deliveryId, branchName, workspace?.link.defaultBranch, repositoryId);
|
||||
showSuccess(`${branchName} created`); await changeRef(branchName);
|
||||
} catch (cause) { showError(getUserFacingError(cause, "De werkbranch kon niet worden gemaakt.")); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const startNewFile = () => {
|
||||
if (actionBusy || !discardAllowed()) return;
|
||||
const normalized = newFilePath.trim().replace(/^\/+/, "");
|
||||
if (!normalized) return;
|
||||
const targetPath = normalized.includes("/") || !path ? normalized : `${path}/${normalized}`;
|
||||
setSelectedFile({ path: targetPath, ref, sha: "", size: 0, content: "" }); setEditorContent("");
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!selectedFile || !editable) return;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
if (!deliveryId) throw new Error("Selecteer eerst het actieve deliveryplan.");
|
||||
const result = await giteaApi.saveFile(resolvedProjectId, { deliveryId, branch: ref, path: selectedFile.path, content: editorContent, message: commitMessage, expectedSha: selectedFile.sha || undefined });
|
||||
showSuccess(`${result.path} is ${result.action === "created" ? "aangemaakt" : "bijgewerkt"} op ${result.branch}`);
|
||||
const file = await giteaApi.file(resolvedProjectId, selectedFile.path, ref, repositoryId); setSelectedFile(file); setEditorContent(file.content);
|
||||
await loadWorkspace(parentPath(selectedFile.path), ref, repositoryId);
|
||||
} catch (cause) { showError(getUserFacingError(cause, "Het bestand kon niet worden gecommit.")); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const deleteFile = async () => {
|
||||
if (!selectedFile?.sha || !editable || !deliveryId || !window.confirm(`Verwijder ${selectedFile.path} uit ${ref}?`)) return;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await giteaApi.deleteFile(resolvedProjectId, { deliveryId, branch: ref, path: selectedFile.path, message: commitMessage, expectedSha: selectedFile.sha });
|
||||
showSuccess(`${selectedFile.path} verwijderd`); setSelectedFile(undefined); setEditorContent(""); await loadWorkspace(parentPath(selectedFile.path), ref, repositoryId);
|
||||
} catch (cause) { showError(cause instanceof Error ? cause.message : "Het bestand kon niet worden verwijderd"); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const syncValidation = async () => {
|
||||
if (!deliveryId) return; setActionBusy(true);
|
||||
try { await giteaApi.syncValidation(resolvedProjectId, deliveryId); showSuccess("Gitea Actions-bewijs geverifieerd"); await loadWorkspace(path, ref, repositoryId); }
|
||||
catch (cause) { showError(cause instanceof Error ? cause.message : "Validatie kon niet worden geverifieerd"); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const createPullRequest = async () => {
|
||||
if (!editable) return;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
if (!deliveryId) throw new Error("Selecteer eerst het actieve deliveryplan.");
|
||||
const result = await giteaApi.createPullRequest(resolvedProjectId, { deliveryId, branch: ref, title: pullRequestTitle, body: pullRequestBody });
|
||||
showSuccess(`Pull request #${result.pullRequest.number} aangemaakt`); await loadWorkspace(path, ref, repositoryId);
|
||||
} catch (cause) { showError(getUserFacingError(cause, "De pull request kon niet worden gemaakt.")); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
const mergePullRequest = async () => {
|
||||
const pullRequest = activePullRequests.find((item) => item.number === selectedDelivery?.spec.pullRequest?.number);
|
||||
if (!deliveryId || !pullRequest || selectedDelivery?.spec.status !== "pull_request_open") return;
|
||||
if (!window.confirm(`Merge pull request #${pullRequest.number} via squash en ruim ${pullRequest.head.ref} daarna op?`)) return;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await giteaApi.mergePullRequest(resolvedProjectId, pullRequest.number, { deliveryId, expectedHeadSha: pullRequest.head.sha, method: "squash", deleteBranch: true });
|
||||
showSuccess(`Pull request #${pullRequest.number} gemerged${result.branchDeleted ? " en branch opgeruimd" : ""}`);
|
||||
setSelectedFile(undefined); setEditorContent("");
|
||||
await loadWorkspace("", workspace?.link.defaultBranch ?? "", repositoryId);
|
||||
} catch (cause) { showError(cause instanceof Error ? cause.message : "De pull request kon niet worden gemerged"); }
|
||||
finally { setActionBusy(false); }
|
||||
};
|
||||
|
||||
return <div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`${project.name} · repository`}
|
||||
subtitle="Bekijk, bewerk en lever de gekoppelde repository rechtstreeks vanuit ProjectBrain. Schrijven blijft beperkt tot de goedgekeurde deliverybranch ook wanneer een open pull request herstel nodig heeft."
|
||||
actions={<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/projects/${project.id}/workspace`)}><ArrowLeft size={15} className="mr-2" />Workspace</Button>
|
||||
<Button variant="outline" onClick={() => void loadWorkspace(path, ref, repositoryId)} disabled={loading}><RefreshCw size={15} className="mr-2" />Vernieuwen</Button>
|
||||
{workspace?.link.htmlUrl ? <Button asChild variant="outline"><a href={workspace.link.htmlUrl} target="_blank" rel="noreferrer">Gitea <ExternalLink size={15} className="ml-2" /></a></Button> : null}
|
||||
</div>}
|
||||
/>
|
||||
|
||||
{error ? <Card className="border-amber-300"><CardContent className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">{projectBrainClientMode === "real" ? "Repository heeft aandacht nodig" : "Werk lokaal verder in Repositorybouwer"}</p><p className="text-sm text-muted-foreground">{error}</p></div><div className="flex flex-wrap gap-2">{projectBrainClientMode === "real" ? <Button variant="outline" onClick={() => void loadWorkspace(path, ref, repositoryId)}>Opnieuw proberen</Button> : <Button variant="outline" onClick={() => navigate(`/projects/${project.id}/overview`)}>Projectoverzicht</Button>}<Button onClick={() => navigate(`/projects/${project.id}/repo-builder#gitea-live-repository`)}>{projectBrainClientMode === "real" ? "Repository koppelen" : "Repository voorbereiden"}</Button></div></CardContent></Card> : null}
|
||||
|
||||
{workspace ? <>
|
||||
<Card className="border-primary/25 bg-primary/5">
|
||||
<CardContent className="grid gap-4 p-5 lg:grid-cols-[1.2fr_1fr_1fr]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2"><GitBranch size={18} className="text-primary" /><p className="font-semibold">{workspace.link.fullName}</p><Badge variant={canWrite ? "default" : "secondary"}>{canWrite ? "lezen + schrijven" : "alleen lezen"}</Badge>{dirty ? <Badge variant="secondary">Niet-gecommitte wijzigingen</Badge> : null}</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Standaardbranch: {workspace.link.defaultBranch}. Rechtstreeks schrijven wordt door de server geblokkeerd.</p>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Repository</Label><Select disabled={actionBusy || loading} value={repositoryId} onValueChange={(value) => void changeRepository(value)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{repositories.map((repository) => <SelectItem key={repository.id} value={repository.id}>{repository.fullName} · {repository.role}{repository.isPrimary ? " · primair" : ""}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="space-y-2"><Label>Actieve branch</Label><Select disabled={actionBusy || loading} value={ref} onValueChange={(value) => void changeRef(value)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{workspace.branches.map((branch) => <SelectItem key={branch.name} value={branch.name}>{branch.name}{branch.protected ? " · beschermd" : ""}</SelectItem>)}</SelectContent></Select></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card><CardHeader><CardTitle>Deliveryplan</CardTitle><CardDescription>Elke branch, commit, validatie en pull request blijft gekoppeld aan één vooraf goedgekeurd plan en exact één repository.</CardDescription></CardHeader><CardContent className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end"><div className="space-y-2"><Label>Actief plan</Label><Select disabled={actionBusy || loading} value={deliveryId} onValueChange={setDeliveryId}><SelectTrigger><SelectValue placeholder="Selecteer een deliveryplan" /></SelectTrigger><SelectContent>{availableDeliveries.map((item) => <SelectItem key={item.id} value={item.id}>{item.title} · v{item.spec.version} · {item.spec.status}</SelectItem>)}</SelectContent></Select>{selectedDelivery?.spec.changedFiles.length ? <p className="text-xs text-muted-foreground">Reeds gewijzigd: {selectedDelivery.spec.changedFiles.join(", ")}</p> : null}</div><Button variant="outline" onClick={() => navigate(`/projects/${project.id}/initiatives`)}>Plannen beheren</Button></CardContent></Card>
|
||||
|
||||
{!editable && canWrite && selectedDelivery?.spec.status === "approved" ? <Card><CardHeader><CardTitle className="flex items-center gap-2"><ShieldCheck size={18} />Veilig repositorywerk starten</CardTitle><CardDescription>Maak een geïsoleerde deliverybranch vanaf {workspace.link.defaultBranch}. Bestaande en nieuwe geldige niet-standaardbranches kunnen daarna door ChatGPT of ProjectBrain worden voortgezet.</CardDescription></CardHeader><CardContent className="flex flex-col gap-3 sm:flex-row"><Input value={branchName} onChange={(event) => setBranchName(event.target.value)} aria-label="Nieuwe deliverybranch" /><Button onClick={() => void createBranch()} disabled={actionBusy || !isEditableBranch(branchName, workspace.link.defaultBranch) || !deliveryId}>{actionBusy ? <Loader2 size={15} className="mr-2 animate-spin" /> : <Plus size={15} className="mr-2" />}Werkbranch maken</Button></CardContent></Card> : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.65fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader><CardTitle>Repositorybestanden</CardTitle><CardDescription>{path || "/"} op {ref}</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{path ? <Button size="sm" variant="ghost" onClick={() => { if (discardAllowed()) void loadWorkspace(parentPath(path), ref, repositoryId); }}><ArrowLeft size={14} className="mr-2" />Bovenliggende map</Button> : null}
|
||||
<div className="max-h-[520px] space-y-1 overflow-auto rounded-lg border p-2">
|
||||
{loading ? <p className="p-3 text-sm text-muted-foreground">Repository laden…</p> : entries.map((entry) => <button key={entry.path} type="button" disabled={actionBusy} onClick={() => void openPath(entry)} className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted"><span className="text-primary">{entry.type === "dir" ? <Folder size={16} /> : <File size={16} />}</span><span className="min-w-0 flex-1 truncate">{entry.name}</span>{entry.type === "file" ? <span className="text-xs text-muted-foreground">{entry.size} B</span> : null}</button>)}
|
||||
{!loading && !entries.length ? <p className="p-3 text-sm text-muted-foreground">Geen bestanden in deze map.</p> : null}
|
||||
</div>
|
||||
{editable ? <div className="space-y-2 rounded-lg border border-dashed p-3"><Label>Bestand maken {path ? `in ${path}` : ""}</Label><div className="flex gap-2"><Input value={newFilePath} onChange={(event) => setNewFilePath(event.target.value)} placeholder="volgende-stap.md" /><Button size="sm" variant="outline" onClick={startNewFile} disabled={!newFilePath.trim()} aria-label="Nieuw bestand voorbereiden"><Plus size={14} /></Button></div></div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0">
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><FileCode2 size={18} />{selectedFile?.path ?? "Bestandseditor"}{dirty ? <Badge variant="secondary">Gewijzigd</Badge> : null}</CardTitle><CardDescription>{selectedFile ? `${selectedFile.size} bytes · ${selectedFile.ref}` : "Selecteer een tekstbestand om het te bekijken."}</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedFile ? <>
|
||||
<Textarea value={editorContent} onChange={(event) => setEditorContent(event.target.value)} readOnly={!editable || actionBusy} className="min-h-[420px] resize-y font-mono text-xs leading-5" spellCheck={false} aria-label="Repository file content" />
|
||||
{dirty && diffStats ? <details className="rounded-lg border bg-muted/30 p-3"><summary className="cursor-pointer text-sm font-medium">Wijzigingsvoorbeeld · {diffStats.changed} gewijzigde regels · {diffStats.before} → {diffStats.after} regels</summary><div className="mt-3 grid gap-3 lg:grid-cols-2"><div><p className="mb-2 text-xs font-medium text-muted-foreground">Voor commit</p><pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-background p-3 text-xs">{selectedFile.content.slice(0, 12_000) || "(nieuw bestand)"}</pre></div><div><p className="mb-2 text-xs font-medium text-muted-foreground">Na commit</p><pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-background p-3 text-xs">{editorContent.slice(0, 12_000) || "(leeg bestand)"}</pre></div></div></details> : null}
|
||||
{editable ? <div className="grid gap-3 md:grid-cols-[1fr_auto_auto] md:items-end"><div className="space-y-2"><Label>Commitbericht</Label><Input value={commitMessage} onChange={(event) => setCommitMessage(event.target.value)} /></div><Button onClick={() => void saveFile()} disabled={actionBusy || !dirty || commitMessage.trim().length < 3}><Save size={15} className="mr-2" />Bestand committen</Button>{selectedFile.sha ? <Button variant="destructive" onClick={() => void deleteFile()} disabled={actionBusy || commitMessage.trim().length < 3}><Trash2 size={15} className="mr-2" />Verwijderen</Button> : null}</div> : <p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">Selecteer het deliveryplan dat deze ChatGPT- of ProjectBrain-branch bezit om te bewerken.</p>}
|
||||
</> : <div className="flex min-h-[420px] flex-col items-center justify-center rounded-lg border border-dashed text-center"><FileCode2 size={32} className="text-muted-foreground" /><p className="mt-3 font-medium">Kies een bestand om te beginnen</p><p className="mt-1 max-w-sm text-sm text-muted-foreground">Je kunt elk veilig tekstbestand bekijken. Bewerken en committen wordt beschikbaar op de toegewezen deliverybranch.</p></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card><CardHeader><CardTitle className="flex items-center gap-2"><GitPullRequest size={18} />Pull requests voor deze branch</CardTitle><CardDescription>Wijzigingen blijven controleerbaar in Gitea voor ze {workspace.link.defaultBranch} bereiken.</CardDescription></CardHeader><CardContent className="space-y-2">{activePullRequests.map((pullRequest) => <a key={pullRequest.id} href={pullRequest.html_url} target="_blank" rel="noreferrer" className="flex items-center justify-between gap-3 rounded-lg border p-3 hover:border-primary/50"><div><p className="font-medium">#{pullRequest.number} {pullRequest.title}</p><p className="text-xs text-muted-foreground">{pullRequest.head.ref} → {pullRequest.base.ref}</p></div><ExternalLink size={14} /></a>)}{!activePullRequests.length ? <p className="text-sm text-muted-foreground">Geen open pull request voor {ref}.</p> : null}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="flex items-center gap-2"><GitCommitHorizontal size={18} />Deze branch opleveren</CardTitle><CardDescription>ProjectBrain controleert automatisch elke 30 seconden. Je kunt de exacte head valideren, een PR openen en die daarna gecontroleerd mergen en opruimen.</CardDescription></CardHeader><CardContent className="space-y-3"><div className="space-y-2"><Label>Pull-requesttitel</Label><Input value={pullRequestTitle} onChange={(event) => setPullRequestTitle(event.target.value)} /></div><div className="space-y-2"><Label>Beschrijving</Label><Textarea value={pullRequestBody} onChange={(event) => setPullRequestBody(event.target.value)} className="min-h-28" /></div><div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => void syncValidation()} disabled={!editable || actionBusy}>Actions verifiëren</Button><Button onClick={() => void createPullRequest()} disabled={!editable || selectedDelivery?.spec.status !== "validating" || actionBusy || pullRequestTitle.trim().length < 3}><GitPullRequest size={15} className="mr-2" />Pull request maken</Button><Button onClick={() => void mergePullRequest()} disabled={selectedDelivery?.spec.status !== "pull_request_open" || !activePullRequests.some((item) => item.number === selectedDelivery.spec.pullRequest?.number) || actionBusy}><GitMerge size={15} className="mr-2" />PR mergen en branch opruimen</Button></div>{!editable && selectedDelivery?.spec.status !== "pull_request_open" ? <p className="text-xs text-muted-foreground">Selecteer de branch en het deliveryplan dat deze branch bezit.</p> : null}</CardContent></Card>
|
||||
</div>
|
||||
</> : null}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default DevelopmentWorkbench;
|
||||
@@ -0,0 +1,786 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Archive, ClipboardCopy, Download, FileCode2, GitBranch, MessageSquareText, PackageCheck, RotateCcw, ShieldCheck, Sparkles, Table2, Workflow } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Textarea } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { BuildLoopActionRail } from "@/shared/components/BuildLoopActionRail";
|
||||
import { ConfirmActionButton } from "@/shared/components/ConfirmActionButton";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { realClient } from "@/shared/api/realClient";
|
||||
import { useProjectCodexTasksData, useProjectReviewQueueData } from "@/shared/hooks/useProjectData";
|
||||
import type { CodexExecutionFeedback, CodexExecutionEvidenceStatus } from "@/shared/lib/model";
|
||||
import {
|
||||
compareFeedbackToHandoff,
|
||||
formatExecutionFeedbackMarkdown,
|
||||
parseCodexExecutionReport,
|
||||
} from "@/shared/lib/codexExecutionFeedback";
|
||||
import { formatCodexSessionPackMarkdown } from "@/shared/lib/codexSessionPack";
|
||||
import { formatFeedbackTemplateMarkdown, formatImportableFeedbackTemplate } from "@/shared/lib/codexSessionPackExport";
|
||||
import { downloadTextFile } from "@/shared/lib/downloads";
|
||||
import { getBuildLoopModuleExplanation } from "@/shared/lib/buildLoopWalkthrough";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
|
||||
const downloadText = (filename: string, content: string) => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const statusVariant = (status: CodexExecutionEvidenceStatus) =>
|
||||
status === "accepted" ? "secondary" : status === "rejected" ? "destructive" : status === "needs-review" ? "default" : "outline";
|
||||
|
||||
const outcomeTone = (outcome: CodexExecutionFeedback["outcome"]) =>
|
||||
outcome === "success" ? "border-l-emerald-500 bg-emerald-50/50" :
|
||||
outcome === "failed" ? "border-l-red-500 bg-red-50/50" :
|
||||
outcome === "partial" ? "border-l-amber-500 bg-amber-50/50" :
|
||||
"border-l-slate-400 bg-muted/20";
|
||||
|
||||
const sampleReport = [
|
||||
"Summary: Codex completed the requested handoff and kept the work inside the intended scope.",
|
||||
"",
|
||||
"Changed files:",
|
||||
"- modified src/features/example/pages/Example.tsx - wired the reviewed flow",
|
||||
"- created docs/EXAMPLE_VALIDATION.md - recorded validation evidence",
|
||||
"",
|
||||
"Validation:",
|
||||
"- pnpm exec tsc --noEmit passed",
|
||||
"- pnpm run lint passed",
|
||||
"- pnpm run build passed",
|
||||
"",
|
||||
"Warnings or residual risks:",
|
||||
"- No warnings or residual risks reported.",
|
||||
"",
|
||||
"What was not changed:",
|
||||
"- No backend, Docker, database, Vite middleware, auth, uploads or AI calls.",
|
||||
].join("\n");
|
||||
|
||||
const feedbackEvidenceExamples = [
|
||||
{
|
||||
label: "Good release evidence",
|
||||
tone: "Release-ready",
|
||||
detail: "Includes changed files, exact validation commands with passed output, residual risks and explicit not-changed scope.",
|
||||
},
|
||||
{
|
||||
label: "Needs reviewer follow-up",
|
||||
tone: "Needs review",
|
||||
detail: "Includes changed files but validation output is incomplete, warnings are vague or expected files do not match the handoff.",
|
||||
},
|
||||
{
|
||||
label: "Not enough evidence",
|
||||
tone: "Do not close",
|
||||
detail: "Only says the task is done, with no commands, no output snippets, no changed file list and no boundary on what was not changed.",
|
||||
},
|
||||
];
|
||||
|
||||
const releaseBlockingEvidenceFields = [
|
||||
"Changed files with action and scope.",
|
||||
"Exact validation commands and pass/fail result.",
|
||||
"Output summary for each validation command.",
|
||||
"Warnings, residual risks or explicit none.",
|
||||
"What was intentionally not changed.",
|
||||
];
|
||||
|
||||
const ExecutionFeedback = () => {
|
||||
const { projectId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isRealMode = projectBrainClientMode === "real";
|
||||
const liveCodexTasks = useProjectCodexTasksData(projectId);
|
||||
const liveReviewQueue = useProjectReviewQueueData(projectId);
|
||||
const {
|
||||
findProjectById,
|
||||
saveCodexExecutionFeedback,
|
||||
updateCodexExecutionFeedbackStatus,
|
||||
archiveCodexExecutionFeedback,
|
||||
createFollowUpHandoffFromFeedback,
|
||||
createExecutionFeedbackFromBridge,
|
||||
} = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
const handoffs = useMemo(() => (project?.codexHandoffs ?? []).filter((handoff) => !handoff.archived), [project]);
|
||||
const sessionPacks = useMemo(() => (project?.codexSessionPacks ?? []).filter((pack) => !pack.archived), [project]);
|
||||
const feedbackItems = useMemo(() => (project?.codexExecutionFeedback ?? []).filter((feedback) => !feedback.archived), [project]);
|
||||
const initialSessionPackId = searchParams.get("sessionPackId") ?? "";
|
||||
const initialBridgeSessionId = searchParams.get("bridgeSessionId") ?? "";
|
||||
const initialCodexTaskId = searchParams.get("codexTaskId") ?? "";
|
||||
const initialFeedbackId = searchParams.get("feedbackId") ?? "";
|
||||
const initialEvidenceStatus = searchParams.get("evidenceStatus") as CodexExecutionEvidenceStatus | null;
|
||||
const openedFromReleaseEvidenceLane = Boolean(initialFeedbackId || initialEvidenceStatus || searchParams.get("intent") === "capture");
|
||||
const initialSessionPack = sessionPacks.find((pack) => pack.id === initialSessionPackId);
|
||||
const initialFeedback =
|
||||
feedbackItems.find((feedback) => feedback.id === initialFeedbackId) ??
|
||||
feedbackItems.find((feedback) => initialEvidenceStatus && feedback.evidenceStatus === initialEvidenceStatus);
|
||||
const [selectedHandoffId, setSelectedHandoffId] = useState(handoffs[0]?.id ?? "");
|
||||
const [selectedSessionPackId, setSelectedSessionPackId] = useState(initialSessionPack?.id ?? "");
|
||||
const [selectedBridgeSessionId, setSelectedBridgeSessionId] = useState(initialBridgeSessionId);
|
||||
const [selectedLiveCodexTaskId, setSelectedLiveCodexTaskId] = useState(initialCodexTaskId);
|
||||
const [lastLiveImportMessage, setLastLiveImportMessage] = useState("");
|
||||
const [selectedFeedbackId, setSelectedFeedbackId] = useState(initialFeedback?.id ?? feedbackItems[0]?.id ?? "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [rawReport, setRawReport] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [showParsedDetails, setShowParsedDetails] = useState(false);
|
||||
|
||||
if (!project || !projectId) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const selectedSessionPack = sessionPacks.find((pack) => pack.id === selectedSessionPackId);
|
||||
const bridgeSessions = (project.browserBridgeSessions ?? []).filter((session) => !session.archived);
|
||||
const selectedBridgeSession = bridgeSessions.find((session) => session.id === selectedBridgeSessionId) ?? bridgeSessions.find((session) => session.responseText && !session.linkedFeedbackId);
|
||||
const selectedHandoff = selectedSessionPack
|
||||
? handoffs.find((handoff) => handoff.id === selectedSessionPack.linkedHandoffId) ?? handoffs.find((handoff) => handoff.id === selectedHandoffId)
|
||||
: handoffs.find((handoff) => handoff.id === selectedHandoffId);
|
||||
const selectedReview = selectedHandoff ? (project.handoffReviews ?? []).find((review) => review.handoffId === selectedHandoff.id && !review.archived) : undefined;
|
||||
const selectedFeedback = feedbackItems.find((feedback) => feedback.id === selectedFeedbackId) ?? feedbackItems[0];
|
||||
const liveTaskOptions = liveCodexTasks.data ?? [];
|
||||
const selectedLiveTask = liveTaskOptions.find((task) => task.id === selectedLiveCodexTaskId) ?? liveTaskOptions[0];
|
||||
const parsedPreview = rawReport.trim() ? parseCodexExecutionReport(rawReport, project, selectedHandoff) : undefined;
|
||||
const previewChangedFiles = parsedPreview?.changedFiles ?? [];
|
||||
const previewValidationResults = parsedPreview?.validationResults ?? [];
|
||||
const previewWarnings = parsedPreview?.warnings ?? [];
|
||||
const previewUnresolvedIssues = parsedPreview?.unresolvedIssues ?? [];
|
||||
const previewFollowUps = parsedPreview?.followUps ?? [];
|
||||
const comparison = selectedFeedback
|
||||
? compareFeedbackToHandoff(project, selectedFeedback, project.codexHandoffs?.find((handoff) => handoff.id === selectedFeedback.linkedHandoffId), project.handoffReviews?.find((review) => review.id === selectedFeedback.linkedHandoffReviewId))
|
||||
: undefined;
|
||||
const moduleExplanation = getBuildLoopModuleExplanation("execution-feedback");
|
||||
|
||||
const saveFeedback = () => {
|
||||
const feedback = saveCodexExecutionFeedback(project.id, {
|
||||
title,
|
||||
rawReport,
|
||||
linkedHandoffId: selectedHandoff?.id,
|
||||
linkedHandoffReviewId: selectedSessionPack?.linkedHandoffReviewId ?? selectedReview?.id,
|
||||
linkedDeliveryPackageId: selectedSessionPack?.linkedDeliveryPackageId ?? selectedHandoff?.linkedDeliveryPackageId,
|
||||
linkedWorkPlanId: selectedSessionPack?.linkedWorkPlanId ?? selectedHandoff?.linkedWorkPlanId,
|
||||
linkedRepoStateSnapshotId: selectedSessionPack?.linkedRepoStateSnapshotId ?? selectedHandoff?.linkedRepoStateSnapshotId,
|
||||
notes,
|
||||
});
|
||||
if (feedback) {
|
||||
setSelectedFeedbackId(feedback.id);
|
||||
setRawReport("");
|
||||
setTitle("");
|
||||
setShowParsedDetails(false);
|
||||
showSuccess("Execution feedback saved");
|
||||
}
|
||||
};
|
||||
|
||||
const importLiveCodexOutput = async () => {
|
||||
if (!projectId || !selectedLiveTask || !rawReport.trim()) return;
|
||||
const summary = title.trim() || parsedPreview?.summary || rawReport.split("\n").find((line) => line.trim())?.trim() || "Codex execution feedback import";
|
||||
const response = await realClient.runtimeV7.importCodexOutput(projectId, selectedLiveTask.id, {
|
||||
summary,
|
||||
outputText: rawReport,
|
||||
sourceRun: selectedLiveTask.id,
|
||||
});
|
||||
await liveCodexTasks.reload();
|
||||
await liveReviewQueue.reload();
|
||||
setLastLiveImportMessage(response.data.mutation.message);
|
||||
setShowParsedDetails(false);
|
||||
showSuccess(response.data.mutation.message);
|
||||
};
|
||||
|
||||
const parseFeedbackPreview = () => {
|
||||
if (!parsedPreview) return;
|
||||
setShowParsedDetails(true);
|
||||
showSuccess(`Parsed ${previewChangedFiles.length} file(s), ${previewValidationResults.length} validation result(s)`);
|
||||
};
|
||||
|
||||
const setEvidenceStatus = (status: CodexExecutionEvidenceStatus) => {
|
||||
if (!selectedFeedback) return;
|
||||
updateCodexExecutionFeedbackStatus(project.id, selectedFeedback.id, status, notes || selectedFeedback.notes);
|
||||
showSuccess(`Execution feedback marked ${status}`);
|
||||
};
|
||||
|
||||
const copyFeedback = async () => {
|
||||
if (!selectedFeedback) return;
|
||||
await navigator.clipboard?.writeText(formatExecutionFeedbackMarkdown(selectedFeedback));
|
||||
showSuccess("Execution feedback copied");
|
||||
};
|
||||
|
||||
const downloadFeedback = () => {
|
||||
if (!selectedFeedback) return;
|
||||
downloadText(`${project.id}-${selectedFeedback.id}.md`, formatExecutionFeedbackMarkdown(selectedFeedback));
|
||||
showSuccess("Execution feedback downloaded");
|
||||
};
|
||||
|
||||
const copySessionPackTemplate = async () => {
|
||||
if (!selectedSessionPack) return;
|
||||
await navigator.clipboard?.writeText([
|
||||
formatCodexSessionPackMarkdown(selectedSessionPack),
|
||||
"",
|
||||
"# Feedback template",
|
||||
formatFeedbackTemplateMarkdown(selectedSessionPack),
|
||||
].join("\n"));
|
||||
showSuccess("Session pack feedback template copied");
|
||||
};
|
||||
|
||||
const downloadSessionPackTemplate = () => {
|
||||
if (!selectedSessionPack) return;
|
||||
downloadTextFile(`${project.id}-${selectedSessionPack.id}-feedback-template.md`, formatFeedbackTemplateMarkdown(selectedSessionPack), "text/markdown;charset=utf-8");
|
||||
showSuccess("Session pack feedback template downloaded");
|
||||
};
|
||||
|
||||
const insertSessionPackTemplate = () => {
|
||||
if (!selectedSessionPack) return;
|
||||
setRawReport(formatImportableFeedbackTemplate(selectedSessionPack));
|
||||
setShowParsedDetails(false);
|
||||
showSuccess("Feedback template inserted");
|
||||
};
|
||||
|
||||
const importBridgeResponse = () => {
|
||||
if (!selectedBridgeSession) return;
|
||||
const feedback = createExecutionFeedbackFromBridge(project.id, selectedBridgeSession.id);
|
||||
if (feedback) {
|
||||
setSelectedFeedbackId(feedback.id);
|
||||
setSelectedBridgeSessionId(selectedBridgeSession.id);
|
||||
showSuccess("Browser bridge response imported as needs-review feedback");
|
||||
}
|
||||
};
|
||||
|
||||
const createFollowUp = () => {
|
||||
if (!selectedFeedback) return;
|
||||
const handoff = createFollowUpHandoffFromFeedback(project.id, selectedFeedback.id);
|
||||
if (handoff) {
|
||||
showSuccess("Follow-up handoff created");
|
||||
navigate(`/projects/${project.id}/handoffs`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Execution Feedback"
|
||||
subtitle="Pasted Codex reports are user-provided execution evidence. Compare them with expected handoff impact and close the build loop without pretending it is live git truth."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/handoffs`)}>
|
||||
<GitBranch size={14} className="mr-2" />Open Handoffs
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
<Workflow size={14} className="mr-2" />Return to Build Loop
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/browser-bridge`)}>
|
||||
<MessageSquareText size={14} className="mr-2" />Browser Bridge
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setRawReport(sampleReport);
|
||||
setShowParsedDetails(false);
|
||||
}}>
|
||||
<Sparkles size={14} className="mr-2" />Load sample
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{openedFromReleaseEvidenceLane ? (
|
||||
<Card className="border-l-4 border-l-emerald-500 bg-emerald-500/5">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">Opened from Release Evidence Lane</Badge>
|
||||
<Badge variant="outline">
|
||||
{initialEvidenceStatus ? `${initialEvidenceStatus} evidence context` : "Execution feedback capture context"}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="mt-3 flex items-center gap-2">
|
||||
<ShieldCheck size={18} /> Execution evidence context
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This page opened from the release evidence lane so you can inspect or capture Codex execution evidence before using it as release proof.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<BuildLoopActionRail
|
||||
projectId={project.id}
|
||||
activeStep="execution-feedback"
|
||||
handoffCount={handoffs.length}
|
||||
sessionPackCount={sessionPacks.length}
|
||||
feedbackCount={isRealMode ? liveCodexTasks.data?.filter((task) => task.status === "imported").length ?? feedbackItems.length : feedbackItems.length}
|
||||
reviewCount={isRealMode ? liveReviewQueue.data?.length ?? 0 : feedbackItems.filter((feedback) => feedback.evidenceStatus === "needs-review" || feedback.evidenceStatus === "draft").length}
|
||||
title="Bring Codex output back into ProjectBrain"
|
||||
description="Paste or import the final Codex report here, then send the resulting evidence to Review Queue before it becomes accepted project state."
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500">
|
||||
<CardHeader>
|
||||
<CardTitle>Execution evidence boundary</CardTitle>
|
||||
<CardDescription>
|
||||
Pasted Codex reports are user-provided execution evidence. Accepted feedback updates ProjectBrain-derived repo state, not live git truth or live filesystem truth.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Evidence examples</CardTitle>
|
||||
<CardDescription>
|
||||
Use these examples before saving feedback. Good evidence lets a reviewer close a build loop without guessing what Codex actually changed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
{feedbackEvidenceExamples.map((example) => (
|
||||
<div key={example.label} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<Badge variant={example.tone === "Release-ready" ? "secondary" : example.tone === "Do not close" ? "destructive" : "outline"}>{example.tone}</Badge>
|
||||
<p className="mt-3 font-semibold">{example.label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{example.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle>Release-blocking fields</CardTitle>
|
||||
<CardDescription>
|
||||
Missing fields should keep evidence in draft or needs-review until the operator adds proof.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{releaseBlockingEvidenceFields.map((field) => (
|
||||
<div key={field} className="flex gap-2 rounded-md border bg-background p-2">
|
||||
<ShieldCheck size={15} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<span>{field}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{moduleExplanation.title}</CardTitle>
|
||||
<CardDescription>
|
||||
Use this when: {moduleExplanation.useWhen} Outputs: {moduleExplanation.outputs} Next step: {moduleExplanation.nextStep}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-4">
|
||||
{[
|
||||
["Paste Codex report", rawReport.trim() || feedbackItems.length ? "ready" : "next"],
|
||||
["Record validation", parsedPreview?.validationResults?.length || selectedFeedback?.validationResults.length ? "ready" : "next"],
|
||||
["Derive repo state", selectedFeedback ? "ready" : "blocked"],
|
||||
["Create follow-up handoff", selectedFeedback?.followUps.length ? "next" : "ready"],
|
||||
].map(([label, state]) => (
|
||||
<div key={label} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<Badge variant={state === "ready" ? "secondary" : state === "blocked" ? "outline" : "default"}>{state}</Badge>
|
||||
<p className="mt-2 font-semibold">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>Live backend Codex output import</CardTitle>
|
||||
<CardDescription>
|
||||
Real-mode bridge: attach pasted Codex output to a persisted backend Codex task and send it to Review Queue before it can become project truth.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={isRealMode ? "default" : "outline"}>{isRealMode ? "Live data" : "Local evidence"}</Badge>
|
||||
{isRealMode ? <Button size="sm" variant="outline" onClick={() => liveCodexTasks.reload()}>Reload tasks</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{!isRealMode ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-muted-foreground">
|
||||
Local mode saves parsed feedback to project-state. Switch `VITE_PROJECTBRAIN_CLIENT_MODE=real` to import output through backend Codex tasks.
|
||||
</div>
|
||||
) : liveCodexTasks.isLoading ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-4 text-muted-foreground">Loading live backend Codex tasks...</div>
|
||||
) : liveCodexTasks.error ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-destructive">{liveCodexTasks.error.message}</div>
|
||||
) : liveTaskOptions.length ? (
|
||||
<>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Live backend Codex task</span>
|
||||
<select
|
||||
value={selectedLiveTask?.id ?? ""}
|
||||
onChange={(event) => setSelectedLiveCodexTaskId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
{liveTaskOptions.map((task) => (
|
||||
<option key={task.id} value={task.id}>{task.goal} ({task.status})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedLiveTask ? (
|
||||
<div className="grid gap-2 rounded-lg border bg-muted/20 p-3 text-xs text-muted-foreground md:grid-cols-4">
|
||||
<span>Status: <span className="text-foreground">{selectedLiveTask.status}</span></span>
|
||||
<span>Approval: <span className="text-foreground">{selectedLiveTask.approvalState}</span></span>
|
||||
<span>Context: <span className="text-foreground">{selectedLiveTask.contextPackId ?? "auto"}</span></span>
|
||||
<span>Validations: <span className="text-foreground">{selectedLiveTask.validationCommands.length}</span></span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" disabled={!rawReport.trim() || !selectedLiveTask} onClick={importLiveCodexOutput}>
|
||||
Import to backend Review Queue
|
||||
</Button>
|
||||
<Badge variant="outline">{liveCodexTasks.data?.length ?? 0} live task(s)</Badge>
|
||||
<Badge variant="outline">{liveReviewQueue.data?.length ?? 0} review item(s)</Badge>
|
||||
</div>
|
||||
{lastLiveImportMessage ? (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 text-emerald-800">
|
||||
{lastLiveImportMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-muted-foreground">
|
||||
No backend Codex tasks yet. Create one from the Live backend handoff path before importing execution output.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-cyan-500 bg-cyan-50/40">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><MessageSquareText size={18} /> Import from browser bridge</CardTitle>
|
||||
<CardDescription>
|
||||
Import a pasted ChatGPT/Codex browser response as draft evidence. No API cost, uses your browser session, and review is still required before acceptance.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit" variant="secondary">{bridgeSessions.length} bridge session(s)</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{bridgeSessions.length ? (
|
||||
<>
|
||||
<label className="space-y-1">
|
||||
<span className="font-medium">Browser bridge session</span>
|
||||
<select
|
||||
value={selectedBridgeSession?.id ?? selectedBridgeSessionId}
|
||||
onChange={(event) => setSelectedBridgeSessionId(event.target.value)}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
{bridgeSessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.title} ({session.status})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedBridgeSession ? (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>{selectedBridgeSession.target}</Badge>
|
||||
<Badge variant="outline">{selectedBridgeSession.status}</Badge>
|
||||
<Badge variant={selectedBridgeSession.linkedFeedbackId ? "secondary" : "outline"}>
|
||||
{selectedBridgeSession.linkedFeedbackId ? "feedback created" : "feedback not created"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{selectedBridgeSession.responseText ? selectedBridgeSession.responseText.slice(0, 220) : "No pasted response yet. Open Browser Bridge to paste the browser answer first."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={!selectedBridgeSession?.responseText || Boolean(selectedBridgeSession.linkedFeedbackId)} onClick={importBridgeResponse}>
|
||||
Import as needs-review feedback
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/browser-bridge`)}>
|
||||
Open Browser Bridge
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-background p-4 text-muted-foreground">
|
||||
No browser bridge sessions yet. Prepare one from a session pack, run it manually in ChatGPT/Codex, then paste the response back here.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><FileCode2 size={18} /> Intake Codex report</CardTitle>
|
||||
<CardDescription>Paste the final Codex report, changed files and validation output after a build round.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Title</span>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="V103 implementation feedback" className="h-10 w-full rounded-md border bg-background px-3 text-sm" />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Linked handoff</span>
|
||||
<select value={selectedHandoffId} onChange={(event) => setSelectedHandoffId(event.target.value)} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||
<option value="">No handoff link</option>
|
||||
{handoffs.map((handoff) => <option key={handoff.id} value={handoff.id}>{handoff.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="font-medium">Linked session pack</span>
|
||||
<select
|
||||
value={selectedSessionPackId}
|
||||
onChange={(event) => {
|
||||
const packId = event.target.value;
|
||||
setSelectedSessionPackId(packId);
|
||||
const pack = sessionPacks.find((item) => item.id === packId);
|
||||
if (pack) setSelectedHandoffId(pack.linkedHandoffId);
|
||||
}}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">No session pack link</option>
|
||||
{sessionPacks.map((pack) => <option key={pack.id} value={pack.id}>{pack.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{selectedSessionPack ? (
|
||||
<Card className="border-l-4 border-l-emerald-500 bg-emerald-50/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base"><PackageCheck size={16} /> Session pack feedback boundary</CardTitle>
|
||||
<CardDescription>Use this template after running the exact session pack in Codex.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>{selectedSessionPack.qualityScore.score}% pack</Badge>
|
||||
<Badge variant="outline">{selectedSessionPack.validationPlan.requiredCommands.length} validations</Badge>
|
||||
<Badge variant={selectedSessionPack.warnings.length ? "destructive" : "secondary"}>{selectedSessionPack.warnings.length} warnings</Badge>
|
||||
</div>
|
||||
<pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded-md border bg-background p-3 text-xs text-muted-foreground">{formatFeedbackTemplateMarkdown(selectedSessionPack)}</pre>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={insertSessionPackTemplate}>Use this feedback template</Button>
|
||||
<Button size="sm" variant="outline" onClick={copySessionPackTemplate}><ClipboardCopy size={14} className="mr-2" />Copy template</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadSessionPackTemplate}><Download size={14} className="mr-2" />Download template</Button>
|
||||
<Button size="sm" variant="outline" onClick={insertSessionPackTemplate}>Insert template into report field</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/session-packs`)}>Open Session Packs</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{selectedHandoff ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{selectedHandoff.sourceType}</Badge>
|
||||
<Badge variant={selectedReview?.decision === "approved" ? "secondary" : "default"}>{selectedReview ? `review ${selectedReview.decision}` : "no impact review"}</Badge>
|
||||
<Badge variant="outline">{selectedHandoff.allowedFiles.length} expected files</Badge>
|
||||
<Badge variant="outline">{selectedHandoff.validationMatrix.length} validation commands</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Textarea
|
||||
value={rawReport}
|
||||
onChange={(event) => {
|
||||
setRawReport(event.target.value);
|
||||
setShowParsedDetails(false);
|
||||
}}
|
||||
placeholder={selectedSessionPack ? "Paste Codex report generated from this session pack, including changed files, validation output, warnings and not-changed notes..." : "Paste Codex final report, changed files, validation output, warnings and not-changed notes..."}
|
||||
className="min-h-[300px] font-mono text-xs"
|
||||
/>
|
||||
<Textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Reviewer notes for this evidence..." className="min-h-20" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={!rawReport.trim()} onClick={saveFeedback}>Save feedback</Button>
|
||||
<Button size="sm" variant="outline" disabled={!rawReport.trim()} onClick={parseFeedbackPreview}>Parse feedback</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{parsedPreview ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Table2 size={18} /> Parsed preview</CardTitle>
|
||||
<CardDescription>
|
||||
Heuristic parse before saving. Use Parse feedback to inspect extracted files, validation, risks and follow-ups.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Outcome</p><p className="text-xl font-semibold">{parsedPreview.outcome}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Changed files</p><p className="text-xl font-semibold">{previewChangedFiles.length}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Validation</p><p className="text-xl font-semibold">{previewValidationResults.length}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Completeness</p><p className="text-xl font-semibold">{parsedPreview.completenessScore}%</p></div>
|
||||
</div>
|
||||
{showParsedDetails ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">Extracted file impact</p>
|
||||
{previewChangedFiles.length ? (
|
||||
<ul className="mt-2 space-y-2 text-xs">
|
||||
{previewChangedFiles.slice(0, 8).map((file) => (
|
||||
<li key={file.path} className="rounded-md border bg-muted/20 p-2">
|
||||
<code>{file.path}</code>
|
||||
<p className="mt-1 text-muted-foreground">{file.action} - {file.riskLevel} risk - expected {file.matchesExpectedImpact ? "yes" : "no"}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="mt-2 text-sm text-muted-foreground">No changed files were detected. Add a Changed files section before saving if this was a code handoff.</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">Validation evidence</p>
|
||||
{previewValidationResults.length ? (
|
||||
<ul className="mt-2 space-y-2 text-xs">
|
||||
{previewValidationResults.slice(0, 8).map((item) => (
|
||||
<li key={item.command} className="rounded-md border bg-muted/20 p-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<code>{item.command}</code>
|
||||
<Badge variant={item.status === "failed" ? "destructive" : item.status === "passed" ? "secondary" : "outline"}>{item.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">{item.outputSnippet}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="mt-2 text-sm text-muted-foreground">No validation commands were detected. Add the exact commands and results before accepting evidence.</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">Warnings and unresolved items</p>
|
||||
{[...previewWarnings, ...previewUnresolvedIssues].length ? (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{[...previewWarnings, ...previewUnresolvedIssues].slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
) : <p className="mt-2 text-sm text-muted-foreground">No warnings or unresolved issues were detected.</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">Suggested follow-ups</p>
|
||||
{previewFollowUps.length ? (
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{previewFollowUps.slice(0, 6).map((item) => (
|
||||
<li key={item.id} className="rounded-md border bg-muted/20 p-2">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.suggestedAction}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="mt-2 text-sm text-muted-foreground">No follow-up handoff is suggested from this preview.</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Saved feedback</CardTitle>
|
||||
<CardDescription>{feedbackItems.length} active feedback record(s).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{feedbackItems.length ? feedbackItems.map((feedback) => (
|
||||
<button key={feedback.id} type="button" onClick={() => setSelectedFeedbackId(feedback.id)} className={`w-full rounded-lg border p-3 text-left transition hover:border-primary/50 ${selectedFeedback?.id === feedback.id ? "border-primary" : "bg-background"}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-semibold">{feedback.title}</p>
|
||||
<Badge variant={statusVariant(feedback.evidenceStatus)}>{feedback.evidenceStatus}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{feedback.outcome} - {feedback.changedFiles.length} files - {feedback.validationResults.length} validations</p>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No execution feedback captured yet. Paste the Codex final report after running an approved handoff; ProjectBrain will treat it as user-provided execution evidence and derive follow-up gaps.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{selectedFeedback ? (
|
||||
<section className="space-y-4">
|
||||
<Card className={`border-l-4 ${outcomeTone(selectedFeedback.outcome)}`}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedFeedback.title}</CardTitle>
|
||||
<CardDescription>{selectedFeedback.summary}</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit" variant={statusVariant(selectedFeedback.evidenceStatus)}>{selectedFeedback.evidenceStatus}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Outcome</p><p className="text-xl font-semibold">{selectedFeedback.outcome}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Completeness</p><p className="text-xl font-semibold">{selectedFeedback.completenessScore}%</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Files</p><p className="text-xl font-semibold">{selectedFeedback.changedFiles.length}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Validation</p><p className="text-xl font-semibold">{selectedFeedback.validationResults.length}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Follow-ups</p><p className="text-xl font-semibold">{selectedFeedback.followUps.length}</p></div>
|
||||
</div>
|
||||
{comparison?.unresolvedSafeguards.length ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-background p-3 text-amber-800">
|
||||
<p className="font-semibold">Mismatch warnings</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">{comparison.unresolvedSafeguards.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={copyFeedback}><ClipboardCopy size={14} className="mr-2" />Copy markdown</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadFeedback}><Download size={14} className="mr-2" />Download</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEvidenceStatus("accepted")}><ShieldCheck size={14} className="mr-2" />Accept evidence</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEvidenceStatus("needs-review")}>Needs review</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEvidenceStatus("rejected")}>Reject</Button>
|
||||
<Button size="sm" variant="outline" disabled={!selectedFeedback.followUps.length} onClick={createFollowUp}><RotateCcw size={14} className="mr-2" />Create follow-up handoff</Button>
|
||||
<ConfirmActionButton
|
||||
title="Archive this execution feedback?"
|
||||
description="The feedback will leave the active feedback list. Accepted project truth and history remain available where they already exist."
|
||||
confirmLabel="Archive feedback"
|
||||
cancelLabel="Keep feedback"
|
||||
onConfirm={() => archiveCodexExecutionFeedback(project.id, selectedFeedback.id)}
|
||||
buttonProps={{ size: "sm", variant: "outline" }}
|
||||
>
|
||||
<Archive size={14} className="mr-2" />Archive
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Claimed changed files</CardTitle></CardHeader>
|
||||
<CardContent className="overflow-auto">
|
||||
<table className="w-full min-w-[640px] text-left text-xs">
|
||||
<thead className="bg-muted/60"><tr><th className="p-2">Path</th><th className="p-2">Action</th><th className="p-2">Risk</th><th className="p-2">Expected</th></tr></thead>
|
||||
<tbody>{selectedFeedback.changedFiles.map((file) => (
|
||||
<tr key={file.path} className="border-t"><td className="p-2 font-mono">{file.path}</td><td className="p-2">{file.action}</td><td className="p-2"><Badge variant={file.riskLevel === "high" ? "destructive" : "outline"}>{file.riskLevel}</Badge></td><td className="p-2">{file.matchesExpectedImpact ? "yes" : "no"}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Validation results</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{selectedFeedback.validationResults.map((item) => (
|
||||
<div key={item.command} className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2"><code className="text-xs">{item.command}</code><Badge variant={item.status === "failed" ? "destructive" : item.status === "passed" ? "secondary" : "outline"}>{item.status}</Badge></div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{item.outputSnippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Follow-ups</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{selectedFeedback.followUps.length ? selectedFeedback.followUps.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.reason}</p>
|
||||
<p className="mt-2 text-xs">{item.suggestedAction}</p>
|
||||
</div>
|
||||
)) : <p className="text-muted-foreground">No follow-ups derived.</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Raw evidence</CardTitle></CardHeader>
|
||||
<CardContent><pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-xs">{selectedFeedback.rawReport}</pre></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionFeedback;
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
FileText,
|
||||
ListChecks,
|
||||
MessageSquareText,
|
||||
PackageCheck,
|
||||
PlayCircle,
|
||||
Route,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Table2,
|
||||
Target,
|
||||
TerminalSquare,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { BuildLoopActionRail } from "@/shared/components/BuildLoopActionRail";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { useOperationRuntime } from "@/features/operations/lib/operationRuntime";
|
||||
import RuntimeExecutionQueue from "@/features/operations/pages/RuntimeExecutionQueue";
|
||||
import RuntimeHandoffBuilder from "@/features/operations/pages/RuntimeHandoffBuilder";
|
||||
import RuntimeContextPromptPilot from "@/features/operations/pages/RuntimeContextPromptPilot";
|
||||
import RuntimeDeliveryPlan from "@/features/operations/pages/RuntimeDeliveryPlan";
|
||||
import { formatSkillsForCodexHandoff, getActiveProjectSkills, getSkillCoverageSummary } from "@/shared/lib/codexSkills";
|
||||
import { getBuildLoopStatus } from "@/shared/lib/buildLoop";
|
||||
import { getBuildLoopModuleExplanation } from "@/shared/lib/buildLoopWalkthrough";
|
||||
import { formatCodexSessionPackMarkdown } from "@/shared/lib/codexSessionPack";
|
||||
import { downloadCodexSessionPackZip } from "@/shared/lib/codexSessionPackExport";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
import { isRealProjectDataMode } from "@/shared/contexts/useProjectDataSource";
|
||||
import { useProjectReviewQueueData } from "@/shared/hooks/useProjectData";
|
||||
|
||||
const approvalRank = {
|
||||
accepted: 0,
|
||||
converted: 1,
|
||||
merged: 2,
|
||||
edited: 3,
|
||||
pending: 4,
|
||||
rejected: 5,
|
||||
blocked: 6,
|
||||
} as const;
|
||||
|
||||
const downloadText = (filename: string, content: string) => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const ExecutionCenter = () => {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { projects, contextPacks, generateFirstImplementationTask, generateCodexHandoff, generateHandoffReview, createBrowserBridgeSession } = useProject();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
const liveReviews = useProjectReviewQueueData(projectId);
|
||||
const runtime = useOperationRuntime(projectId);
|
||||
const [markdownPreview, setMarkdownPreview] = useState("");
|
||||
|
||||
const starterReviews = isRealProjectDataMode ? liveReviews.data ?? [] : project?.starterReviewItems ?? [];
|
||||
const firstImplementationItem = starterReviews.find((item) => item.source === "Guided start execution flow");
|
||||
const acceptedStarterReviews = starterReviews
|
||||
.filter((item) => ["accepted", "converted", "merged", "edited"].includes(item.attribution?.approvalState ?? "pending"))
|
||||
.sort((a, b) => (approvalRank[a.attribution?.approvalState as keyof typeof approvalRank] ?? 9) - (approvalRank[b.attribution?.approvalState as keyof typeof approvalRank] ?? 9));
|
||||
const pendingStarterReviews = starterReviews.filter((item) => !item.attribution?.approvalState || item.attribution.approvalState === "pending");
|
||||
const activeContextPack = contextPacks.find((pack) => pack.id === project?.activeContextPackId) ?? contextPacks.find((pack) => pack.projectId === project?.id);
|
||||
const openRuntimeTodos = runtime.todoItems.filter((item) => item.status !== "done");
|
||||
const acceptedTruthWithoutHandoff = runtime.truthItems.filter(
|
||||
(truth) => !runtime.handoffBundles.some((bundle) => bundle.sourceTruthItemIds.includes(truth.id)),
|
||||
);
|
||||
const activeSkills = project ? getActiveProjectSkills(project) : [];
|
||||
const skillCoverage = project ? getSkillCoverageSummary(project, activeSkills) : undefined;
|
||||
const activeHandoffs = (project?.codexHandoffs ?? []).filter((handoff) => !handoff.archived);
|
||||
const recommendedHandoff = activeHandoffs.sort((left, right) => right.qualityScore.score - left.qualityScore.score)[0];
|
||||
const recommendedReview = recommendedHandoff ? (project?.handoffReviews ?? []).find((review) => review.handoffId === recommendedHandoff.id && !review.archived) : undefined;
|
||||
const activeSessionPacks = (project?.codexSessionPacks ?? []).filter((pack) => !pack.archived);
|
||||
const recommendedSessionPack = recommendedHandoff
|
||||
? activeSessionPacks.find((pack) => pack.linkedHandoffId === recommendedHandoff.id) ?? activeSessionPacks[0]
|
||||
: activeSessionPacks[0];
|
||||
const activeExecutionFeedback = (project?.codexExecutionFeedback ?? []).filter((feedback) => !feedback.archived);
|
||||
const latestFeedback = activeExecutionFeedback[0];
|
||||
const buildLoopStatus = project ? getBuildLoopStatus(project) : undefined;
|
||||
const nextDeliveryPackage = (project?.repoDeliveryPackages ?? []).find((deliveryPackage) => deliveryPackage.status !== "archived");
|
||||
const moduleExplanation = getBuildLoopModuleExplanation("execution-center");
|
||||
|
||||
const executionReadiness = useMemo(() => {
|
||||
const checks = [
|
||||
{ label: "First implementation task exists", passed: Boolean(firstImplementationItem), route: project ? `/projects/${project.id}/workspace?next=implementation` : "/projects" },
|
||||
{ label: "Review queue has approved execution input", passed: acceptedStarterReviews.length > 0 || runtime.truthItems.length > 0, route: project ? `/projects/${project.id}/review-queue` : "/projects" },
|
||||
{ label: "Context pack is available", passed: Boolean(activeContextPack), route: project ? `/projects/${project.id}/context-packs` : "/projects" },
|
||||
{ label: "Codex Skills are configured", passed: activeSkills.length > 0, route: project ? `/projects/${project.id}/skills` : "/projects" },
|
||||
{ label: "QA/QC skill is active", passed: Boolean(skillCoverage?.hasQaSkill), route: project ? `/projects/${project.id}/skills` : "/projects" },
|
||||
{ label: "Delivery queue has work", passed: openRuntimeTodos.length > 0 || acceptedTruthWithoutHandoff.length > 0, route: project ? `/projects/${project.id}/runtime` : "/projects" },
|
||||
];
|
||||
return {
|
||||
checks,
|
||||
score: Math.round((checks.filter((check) => check.passed).length / checks.length) * 100),
|
||||
};
|
||||
}, [acceptedStarterReviews.length, acceptedTruthWithoutHandoff.length, activeContextPack, activeSkills.length, firstImplementationItem, openRuntimeTodos.length, project, runtime.truthItems.length, skillCoverage?.hasQaSkill]);
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const firstSlice = project.firstRun?.firstSlice ?? project.workspaceState?.primaryObjective ?? project.description;
|
||||
const executionMarkdown = [
|
||||
`# ${project.name} — Execution Brief`,
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
"",
|
||||
"## First slice",
|
||||
firstSlice,
|
||||
"",
|
||||
"## Readiness",
|
||||
`Score: ${executionReadiness.score}%`,
|
||||
...executionReadiness.checks.map((check) => `- ${check.passed ? "[x]" : "[ ]"} ${check.label}`),
|
||||
"",
|
||||
"## Review-gated execution inputs",
|
||||
...(acceptedStarterReviews.length
|
||||
? acceptedStarterReviews.map((item) => `- ${item.title} — ${item.attribution.approvalState}: ${item.description}`)
|
||||
: ["- No accepted starter review input yet."]),
|
||||
"",
|
||||
"## Runtime work",
|
||||
`- Open runtime todos: ${openRuntimeTodos.length}`,
|
||||
`- Accepted truth without handoff: ${acceptedTruthWithoutHandoff.length}`,
|
||||
`- Handoff bundles: ${runtime.handoffBundles.length}`,
|
||||
"",
|
||||
"## Active Codex Skills",
|
||||
skillCoverage?.summary ?? "No skill coverage summary.",
|
||||
...activeSkills.map((skill) => `- ${skill.name}: ${skill.description}`),
|
||||
"",
|
||||
"## Skill validation commands",
|
||||
...(skillCoverage?.validationCommands.length ? skillCoverage.validationCommands.map((command) => `- ${command}`) : ["- No skill validation commands selected."]),
|
||||
"",
|
||||
formatSkillsForCodexHandoff(activeSkills),
|
||||
"",
|
||||
"## Recommended next prompt",
|
||||
activeContextPack
|
||||
? `Use context pack "${activeContextPack.title}" and execute only the first slice. Keep output reviewable and include changed files, validation evidence and rollback notes.`
|
||||
: "Create or select a context pack first, then execute only the first slice with validation evidence and rollback notes.",
|
||||
].join("\n");
|
||||
|
||||
const copyExecutionBrief = async () => {
|
||||
setMarkdownPreview(executionMarkdown);
|
||||
try {
|
||||
await navigator.clipboard?.writeText(executionMarkdown);
|
||||
showSuccess("Execution brief copied");
|
||||
} catch {
|
||||
showSuccess("Execution brief prepared; clipboard unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadExecutionBrief = () => {
|
||||
setMarkdownPreview(executionMarkdown);
|
||||
downloadText(`${project.id}-execution-brief.md`, executionMarkdown);
|
||||
showSuccess("Execution brief downloaded");
|
||||
};
|
||||
|
||||
const prepareFirstTask = () => {
|
||||
if (firstImplementationItem) {
|
||||
navigate(`/projects/${project.id}/review-queue`);
|
||||
return;
|
||||
}
|
||||
generateFirstImplementationTask(project.id);
|
||||
showSuccess("First implementation task queued for review");
|
||||
};
|
||||
|
||||
const createExecutionHandoff = () => {
|
||||
runtime.createHandoffBundle("codex-task", `Execution handoff for ${project.name}`);
|
||||
showSuccess("Execution handoff created from accepted runtime context");
|
||||
};
|
||||
|
||||
const generateQualityHandoff = () => {
|
||||
const sourceId = nextDeliveryPackage?.id ?? project.repoWorkPlans?.find((plan) => plan.status !== "archived")?.id ?? project.repoStateSnapshots?.find((snapshot) => snapshot.status !== "archived")?.id;
|
||||
const sourceType = nextDeliveryPackage ? "delivery-package" : project.repoWorkPlans?.some((plan) => plan.id === sourceId) ? "work-plan" : "repo-state-snapshot";
|
||||
if (!sourceId) {
|
||||
navigate(`/projects/${project.id}/repo-builder`);
|
||||
return;
|
||||
}
|
||||
const handoff = generateCodexHandoff(project.id, { sourceType, sourceId });
|
||||
if (handoff) {
|
||||
setMarkdownPreview(handoff.promptText);
|
||||
showSuccess("Codex handoff generated");
|
||||
}
|
||||
};
|
||||
|
||||
const copyNextCodexPrompt = async () => {
|
||||
if (recommendedSessionPack) {
|
||||
const markdown = formatCodexSessionPackMarkdown(recommendedSessionPack);
|
||||
setMarkdownPreview(markdown);
|
||||
await navigator.clipboard?.writeText(markdown);
|
||||
showSuccess("Codex session pack copied");
|
||||
return;
|
||||
}
|
||||
if (!recommendedHandoff) {
|
||||
generateQualityHandoff();
|
||||
return;
|
||||
}
|
||||
setMarkdownPreview(recommendedHandoff.promptText);
|
||||
await navigator.clipboard?.writeText(recommendedHandoff.promptText);
|
||||
showSuccess(recommendedReview?.decision === "approved" ? "Approved Codex prompt copied" : "Codex prompt copied; review approval is still recommended");
|
||||
};
|
||||
|
||||
const downloadRecommendedSessionPackZip = () => {
|
||||
if (!recommendedSessionPack) return;
|
||||
downloadCodexSessionPackZip(recommendedSessionPack);
|
||||
showSuccess("Session pack ZIP downloaded");
|
||||
};
|
||||
|
||||
const prepareBrowserBridge = () => {
|
||||
if (!recommendedSessionPack) {
|
||||
navigate(`/projects/${project.id}/session-packs`);
|
||||
return;
|
||||
}
|
||||
const session = createBrowserBridgeSession(project.id, recommendedSessionPack.id, "chatgpt");
|
||||
if (session) {
|
||||
showSuccess("No API cost browser bridge prepared");
|
||||
navigate(`/projects/${project.id}/browser-bridge`);
|
||||
}
|
||||
};
|
||||
|
||||
const generateImpactReview = () => {
|
||||
if (!recommendedHandoff) {
|
||||
generateQualityHandoff();
|
||||
return;
|
||||
}
|
||||
const review = generateHandoffReview(project.id, recommendedHandoff.id);
|
||||
if (review) {
|
||||
setMarkdownPreview(`Impact review: ${review.title}\nRisk: ${review.riskSummary.highestRisk}\nValidation: ${review.validationCoverage.coverageScore}%\nDecision: ${review.decision}`);
|
||||
showSuccess("Impact review generated");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Execution Center"
|
||||
subtitle="Turn reviewed context into the next implementation handoff without losing the review gate."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" data-page-primary="true" onClick={prepareFirstTask}>
|
||||
<PlayCircle size={14} className="mr-2" />
|
||||
{firstImplementationItem ? "Review first task" : "Generate first task"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={copyExecutionBrief}>
|
||||
<ClipboardCopy size={14} className="mr-2" />Copy brief
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadExecutionBrief}>
|
||||
<Download size={14} className="mr-2" />Download brief
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
<Workflow size={14} className="mr-2" />View build loop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<BuildLoopActionRail
|
||||
projectId={project.id}
|
||||
activeStep="execution"
|
||||
promptCount={recommendedHandoff || recommendedSessionPack ? 1 : 0}
|
||||
handoffCount={activeHandoffs.length}
|
||||
sessionPackCount={activeSessionPacks.length}
|
||||
executionCount={openRuntimeTodos.length || acceptedTruthWithoutHandoff.length ? 1 : 0}
|
||||
feedbackCount={activeExecutionFeedback.length}
|
||||
reviewCount={starterReviews.length + (project.handoffReviews ?? []).filter((review) => !review.archived).length}
|
||||
truthCount={activeExecutionFeedback.filter((feedback) => feedback.evidenceStatus === "accepted").length}
|
||||
currentStateCount={latestFeedback?.evidenceStatus === "accepted" ? 1 : 0}
|
||||
title="Execution stays inside the build loop"
|
||||
description="Run the selected prompt or session pack, then return with changed files, validation output and follow-up evidence instead of treating execution as the finish line."
|
||||
/>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{moduleExplanation.title}</CardTitle>
|
||||
<CardDescription>
|
||||
Use this when: {moduleExplanation.useWhen} Outputs: {moduleExplanation.outputs} Next step: {moduleExplanation.nextStep}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-primary bg-primary/5">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Target size={18} /> Start-to-execution bridge</CardTitle>
|
||||
<CardDescription className="mt-1 max-w-3xl">
|
||||
This page connects Guided Start, Source Library, Review Queue, Context Packs, Prompt Builder and runtime handoff into one delivery path.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit text-sm">{executionReadiness.score}% ready</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
{executionReadiness.checks.map((check, index) => (
|
||||
<button key={check.label} type="button" onClick={() => navigate(check.route)} className="rounded-xl border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold">{index + 1}</span>
|
||||
<Badge variant={check.passed ? "secondary" : "outline"}>{check.passed ? "ready" : "needed"}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-semibold">{check.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={skillCoverage?.hasQaSkill ? "border-l-4 border-l-emerald-500" : "border-l-4 border-l-amber-500 bg-amber-50/50"}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Sparkles size={18} /> Active Codex Skills</CardTitle>
|
||||
<CardDescription>
|
||||
Execution briefs include these skill instructions, validation commands and quality checklist items.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeSkills.map((skill) => <Badge key={skill.id} variant="secondary">{skill.name}</Badge>)}
|
||||
{!activeSkills.length ? <Badge variant="outline">No active skills</Badge> : null}
|
||||
</div>
|
||||
{!skillCoverage?.hasQaSkill ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-background p-3 text-sm text-amber-800">
|
||||
No QA/QC skill is active for this execution path. Enable `qa-qc-review` before treating a delivery package as ready.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/skills`)}>Configure skills</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setMarkdownPreview(formatSkillsForCodexHandoff(activeSkills))}>Preview skill block</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={recommendedHandoff?.warnings.length ? "border-l-4 border-l-amber-500 bg-amber-50/50" : "border-l-4 border-l-cyan-500 bg-cyan-50/40"}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><TerminalSquare size={18} /> Codex Handoff Quality Engine</CardTitle>
|
||||
<CardDescription>
|
||||
Execution should use controlled handoffs with allowed files, forbidden changes, validation matrix, rollback and final report format.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit">{recommendedHandoff ? `${recommendedHandoff.qualityScore.score}% quality` : "handoff needed"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{recommendedHandoff ? (
|
||||
<>
|
||||
<p className="font-semibold">{recommendedHandoff.title}</p>
|
||||
<p className="text-muted-foreground">{recommendedHandoff.objective}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{recommendedHandoff.sourceType}</Badge>
|
||||
<Badge variant="outline">{recommendedHandoff.allowedFiles.length} allowed files</Badge>
|
||||
<Badge variant={recommendedHandoff.warnings.length ? "destructive" : "secondary"}>{recommendedHandoff.warnings.length} warnings</Badge>
|
||||
<Badge variant={recommendedReview?.decision === "approved" ? "secondary" : "destructive"}>{recommendedReview ? `review ${recommendedReview.decision}` : "no impact review"}</Badge>
|
||||
{recommendedReview ? <Badge variant="outline">risk {recommendedReview.riskSummary.highestRisk}</Badge> : null}
|
||||
{recommendedReview ? <Badge variant="outline">validation {recommendedReview.validationCoverage.coverageScore}%</Badge> : null}
|
||||
</div>
|
||||
{recommendedReview?.decision !== "approved" ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-background p-3 text-amber-800">
|
||||
Recommended to approve the impact review before executing this prompt.
|
||||
</div>
|
||||
) : null}
|
||||
{recommendedHandoff.warnings.length ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-amber-800">
|
||||
{recommendedHandoff.warnings.slice(0, 4).map((warning) => <li key={warning}>{warning}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No Codex handoff exists yet. Generate one from the next delivery package, work plan or repo state snapshot before copying a build prompt.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={copyNextCodexPrompt}><ClipboardCopy size={14} className="mr-2" />Copy next Codex prompt</Button>
|
||||
<Button size="sm" variant="outline" onClick={generateQualityHandoff}><Sparkles size={14} className="mr-2" />Generate handoff</Button>
|
||||
<Button size="sm" variant="outline" onClick={generateImpactReview}><Table2 size={14} className="mr-2" />Review impact</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/handoffs`)}>Open Codex Handoff</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={recommendedSessionPack?.warnings.length ? "border-l-4 border-l-amber-500 bg-amber-50/50" : "border-l-4 border-l-emerald-500 bg-emerald-50/40"}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><PackageCheck size={18} /> Codex Session Pack</CardTitle>
|
||||
<CardDescription>
|
||||
The runnable bundle combines handoff, impact review, validation plan, rollback notes and feedback template for the next Codex session.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit">{recommendedSessionPack ? `${recommendedSessionPack.qualityScore.score}% pack` : "pack needed"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{recommendedSessionPack ? (
|
||||
<>
|
||||
<p className="font-semibold">{recommendedSessionPack.title}</p>
|
||||
<p className="text-muted-foreground">{recommendedSessionPack.objective}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{recommendedSessionPack.sections.length} sections</Badge>
|
||||
<Badge variant="outline">{recommendedSessionPack.allowedFiles.length} allowed files</Badge>
|
||||
<Badge variant="outline">{recommendedSessionPack.validationPlan.requiredCommands.length} validations</Badge>
|
||||
<Badge variant={recommendedSessionPack.linkedHandoffReviewId ? "secondary" : "destructive"}>{recommendedSessionPack.linkedHandoffReviewId ? "review linked" : "review missing"}</Badge>
|
||||
<Badge variant={recommendedSessionPack.warnings.length ? "destructive" : "secondary"}>{recommendedSessionPack.warnings.length} warnings</Badge>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No session pack exists for the recommended handoff yet. Create one after the impact review is approved, then run that pack in Codex.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={copyNextCodexPrompt}><ClipboardCopy size={14} className="mr-2" />Run in Codex</Button>
|
||||
{recommendedSessionPack ? (
|
||||
<Button size="sm" variant="outline" onClick={downloadRecommendedSessionPackZip}>
|
||||
<Download size={14} className="mr-2" />Download ZIP
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/session-packs`)}>
|
||||
<PackageCheck size={14} className="mr-2" />Open Session Packs
|
||||
</Button>
|
||||
{recommendedSessionPack ? (
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback?sessionPackId=${recommendedSessionPack.id}`)}>
|
||||
Add feedback for pack
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-cyan-500 bg-cyan-50/40">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><MessageSquareText size={18} /> No API cost browser bridge</CardTitle>
|
||||
<CardDescription>
|
||||
Run the selected session pack in ChatGPT/Codex through your own browser session. ProjectBrain only prepares prompts and parses pasted responses; execution evidence still requires review.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit" variant="secondary">user controlled</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={prepareBrowserBridge}>
|
||||
<MessageSquareText size={14} className="mr-2" />Prepare bridge
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/browser-bridge`)}>
|
||||
Open Browser Bridge
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>
|
||||
Paste result as feedback
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={latestFeedback?.evidenceStatus === "accepted" ? "border-l-4 border-l-emerald-500 bg-emerald-50/40" : "border-l-4 border-l-violet-500 bg-violet-50/40"}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Table2 size={18} /> Codex Execution Feedback</CardTitle>
|
||||
<CardDescription>
|
||||
Close the build loop by recording Codex final reports, claimed changed files, validation output and follow-ups as user-provided evidence.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit">{latestFeedback ? latestFeedback.evidenceStatus : "feedback needed"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{latestFeedback ? (
|
||||
<>
|
||||
<p className="font-semibold">{latestFeedback.title}</p>
|
||||
<p className="text-muted-foreground">{latestFeedback.summary}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">outcome {latestFeedback.outcome}</Badge>
|
||||
<Badge variant="outline">{latestFeedback.changedFiles.length} changed files</Badge>
|
||||
<Badge variant="outline">{latestFeedback.validationResults.filter((item) => item.status === "passed").length}/{latestFeedback.validationResults.length} validation passed</Badge>
|
||||
<Badge variant={latestFeedback.followUps.length ? "destructive" : "secondary"}>{latestFeedback.followUps.length} follow-ups</Badge>
|
||||
</div>
|
||||
{latestFeedback.followUps.length ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-amber-800">
|
||||
{latestFeedback.followUps.slice(0, 4).map((item) => <li key={item.id}>{item.title}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No execution feedback has been captured yet. After running Codex, paste the final report before moving to the next package.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>Open Execution Feedback</Button>
|
||||
{buildLoopStatus ? <Button size="sm" variant="outline" onClick={() => navigate(buildLoopStatus.recommendedAction.route)}>{buildLoopStatus.recommendedAction.label}</Button> : null}
|
||||
<Button size="sm" variant="outline" disabled={!recommendedHandoff} onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>Add feedback for handoff</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Review-gated execution inputs</CardTitle>
|
||||
<CardDescription>Only accepted, edited, merged or converted starter-review items are treated as execution input.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{acceptedStarterReviews.length ? acceptedStarterReviews.slice(0, 6).map((item) => (
|
||||
<div key={item.id} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold">{item.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Badge>{item.attribution.approvalState}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
No accepted starter review input yet. Start by reviewing pending source, document or first-task candidates.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/review-queue`)}>
|
||||
<ListChecks size={14} className="mr-2" />Open Review Queue
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/source-library`)}>
|
||||
<FileText size={14} className="mr-2" />Add sources
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Sparkles size={18} /> Next execution move</CardTitle>
|
||||
<CardDescription>Choose the safest next step for implementation.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="text-xs text-muted-foreground">First slice</p>
|
||||
<p className="mt-1 font-medium">{firstSlice}</p>
|
||||
</div>
|
||||
<Button className="w-full justify-start" onClick={() => navigate(activeContextPack ? `/projects/${project.id}/prompts?contextPackId=${activeContextPack.id}&objective=${encodeURIComponent(firstSlice)}` : `/projects/${project.id}/prompts`)}>
|
||||
<Route size={14} className="mr-2" />Open Prompt Builder
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" onClick={createExecutionHandoff}>
|
||||
<ArrowRight size={14} className="mr-2" />Create execution handoff
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" onClick={() => navigate(`/projects/${project.id}/history`)}>
|
||||
<CheckCircle2 size={14} className="mr-2" />Open delivery history
|
||||
</Button>
|
||||
<div className="rounded-lg border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
Pending starter reviews: {pendingStarterReviews.length}. Open runtime todos: {openRuntimeTodos.length}. Accepted truth without handoff: {acceptedTruthWithoutHandoff.length}.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<RuntimeExecutionQueue projectId={project.id} />
|
||||
<RuntimeDeliveryPlan projectId={project.id} />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<RuntimeHandoffBuilder projectId={project.id} compact />
|
||||
<RuntimeContextPromptPilot projectId={project.id} compact />
|
||||
</div>
|
||||
|
||||
{markdownPreview ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Execution brief preview</CardTitle>
|
||||
<CardDescription>This is the exact markdown copied/downloaded for implementation handoff.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">{markdownPreview}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionCenter;
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { StatusBadge, ConfidenceBadge } from "@/components/ui";
|
||||
import { EvidenceList } from "@/components/ui/EvidenceList";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { Bug, CheckSquare, MessageSquare, Link2, Brain, GitBranch, ShieldAlert, RefreshCw, BrainCircuit } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { useFeatureEvidenceData, useProjectFeatureDetailData } from "@/shared/hooks/useProjectData";
|
||||
|
||||
type TrustPayload = {
|
||||
source: string;
|
||||
confidence: number;
|
||||
evidence: Array<{ id: string; type: string; refId: string; label: string }>;
|
||||
lastChecked: string;
|
||||
approvalState: string;
|
||||
};
|
||||
|
||||
const TrustPanel = ({ trust, title }: { title: string; trust: TrustPayload }) => (
|
||||
<Card className="p-3">
|
||||
<p className="text-sm font-semibold mb-2">{title} trust</p>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Source: <span className="text-foreground">{trust.source}</span></p>
|
||||
<p>Confidence: <span className="text-foreground">{trust.confidence}%</span></p>
|
||||
<p>Evidence count: <span className="text-foreground">{trust.evidence.length}</span></p>
|
||||
<p>Last checked: <span className="text-foreground">{new Date(trust.lastChecked).toLocaleString()}</span></p>
|
||||
<p>Approval state: <span className="text-foreground">{trust.approvalState}</span></p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const FeatureDetail = () => {
|
||||
const { projectId, featureId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { projects, relationships, snapshots, checks, evidence, contextPacks } = useProject();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const featureResource = useProjectFeatureDetailData(projectId, featureId);
|
||||
const evidenceResource = useFeatureEvidenceData(projectId, featureId);
|
||||
const fallbackFeature = project?.featureGroups.flatMap((g) => g.features).find((f) => f.id === featureId);
|
||||
const feature = featureResource.data ?? fallbackFeature;
|
||||
const centralEvidence = evidenceResource.data ?? evidence;
|
||||
const featureEvidence = centralEvidence.filter((entry) =>
|
||||
entry.linkedEntity.id === feature?.id ||
|
||||
entry.featureIds?.includes(feature?.id ?? "") ||
|
||||
entry.trustEvidence?.some((ref) => ref.refId === feature?.id),
|
||||
);
|
||||
|
||||
if (!project || !feature) return <div className="text-center py-20">Feature not found</div>;
|
||||
|
||||
const featureRelationships = relationships.filter(
|
||||
(relationship) => relationship.from === feature.id || relationship.to === feature.id,
|
||||
);
|
||||
const activePack = contextPacks.find((pack) => pack.id === project.activeContextPackId);
|
||||
const includedInActivePack = Boolean(activePack?.featureIds?.includes(feature.id));
|
||||
const includedChecks = checks.filter((check) => check.linkedFeatureIds?.includes(feature.id));
|
||||
const historySnapshots = snapshots.filter(
|
||||
(snapshot) => snapshot.itemsAdded.includes(feature.id) || snapshot.itemsRemoved.includes(feature.id),
|
||||
);
|
||||
const featureTrust: TrustPayload = {
|
||||
source: feature.trust.source,
|
||||
confidence: feature.trust.confidence,
|
||||
evidence: feature.trust.evidence ?? [],
|
||||
lastChecked: feature.trust.lastChecked,
|
||||
approvalState: feature.trust.approvalState,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<Button variant="outline" size="sm" className="mb-4 gap-2" onClick={() => navigate(`/projects/${project.id}/features`)}>
|
||||
<Link2 size={14} /> Back to Feature Map
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">{feature.name}</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge status={feature.status} />
|
||||
<ConfidenceBadge confidence={feature.confidence} />
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => navigate(`/projects/${project.id}/context-packs?source=feature&featureId=${feature.id}`)}
|
||||
>
|
||||
<Brain size={14} /> Generate Context Pack
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => navigate(`/projects/${project.id}/brainstorm?scope=feature&mode=implementation&targetEntityType=feature&targetEntityId=${feature.id}&title=${encodeURIComponent(feature.name)}&prompt=${encodeURIComponent(feature.description)}`)}
|
||||
>
|
||||
<BrainCircuit size={14} /> Brainstorm feature
|
||||
</Button>
|
||||
<Badge variant={includedInActivePack ? "default" : "secondary"}>
|
||||
{includedInActivePack ? "Included in active context pack" : "Not in active context pack"}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/projects/${project.id}/checks`)}
|
||||
>
|
||||
<ShieldAlert size={14} /> Open checks
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Feature detail data source</p>
|
||||
<p className="text-muted-foreground">
|
||||
{projectBrainClientMode === "real"
|
||||
? "Live data mode reads the feature and evidence from the backend."
|
||||
: "Local data mode reads the local feature map and evidence fixtures."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={projectBrainClientMode === "real" ? "default" : "secondary"}>
|
||||
{projectBrainClientMode === "real" ? "Live data" : "Local data"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
featureResource.reload();
|
||||
evidenceResource.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className="mr-2" /> Reload detail
|
||||
</Button>
|
||||
</div>
|
||||
{(featureResource.error || evidenceResource.error) && (
|
||||
<p className="basis-full text-xs text-destructive">
|
||||
Feature detail read failed: {(featureResource.error ?? evidenceResource.error)?.message}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="grid w-full md:grid-cols-6">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="evidence">Evidence</TabsTrigger>
|
||||
<TabsTrigger value="analysis">AI Analysis</TabsTrigger>
|
||||
<TabsTrigger value="linked">Linked Work</TabsTrigger>
|
||||
<TabsTrigger value="relationships">Relationships</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feature dossier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>Feature status: {feature.status}</p>
|
||||
<p>Health score: {feature.health}</p>
|
||||
<p>Linked bugs: {feature.bugs.length}</p>
|
||||
<p>Linked tasks: {feature.tasks.length}</p>
|
||||
<p>Linked decisions: {feature.decisions.length}</p>
|
||||
<p>Evidence artifacts: {feature.codeEvidence.length + featureEvidence.length}</p>
|
||||
<p>Active context pack: {activePack?.title ?? "none"}</p>
|
||||
<p>
|
||||
Checks linked to this feature: {includedChecks.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TrustPanel title="Feature" trust={featureTrust} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="evidence" className="space-y-4">
|
||||
<EvidenceList items={feature.codeEvidence} />
|
||||
<div className="grid gap-3">
|
||||
{featureEvidence.length === 0 ? (
|
||||
<Card className="p-3">
|
||||
<p className="text-sm text-muted-foreground">No central evidence artifacts linked yet.</p>
|
||||
</Card>
|
||||
) : (
|
||||
featureEvidence.map((entry) => (
|
||||
<Card key={entry.id} className="p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">{entry.linkedEntity.label}</p>
|
||||
<Badge variant="outline">{entry.type}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{entry.reason}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Source: {entry.source} • Confidence: {entry.confidence}%
|
||||
</p>
|
||||
{entry.route && <Link className="text-xs underline" to={entry.route}>Open target</Link>}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<TrustPanel title="Evidence index" trust={featureTrust} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analysis" className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
{feature.aiSuggestions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No AI findings for this feature yet.</p>
|
||||
) : (
|
||||
feature.aiSuggestions.map((suggestion) => (
|
||||
<Card key={suggestion.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{suggestion.title}</CardTitle>
|
||||
<CardDescription>{suggestion.type}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
||||
<p className="text-xs text-muted-foreground">File: {suggestion.file}</p>
|
||||
<TrustPanel
|
||||
title="AI suggestion"
|
||||
trust={{
|
||||
source: suggestion.trust?.source ?? "unknown",
|
||||
confidence: suggestion.trust?.confidence ?? featureTrust.confidence,
|
||||
evidence: suggestion.trust?.evidence ?? featureTrust.evidence,
|
||||
lastChecked: suggestion.trust?.lastChecked ?? featureTrust.lastChecked,
|
||||
approvalState: suggestion.trust?.approvalState ?? featureTrust.approvalState,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => showFeatureCheckLinks(project.id, feature.id, navigate)}
|
||||
>
|
||||
Open related checks
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="linked" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked work items</CardTitle>
|
||||
<CardDescription>All linked bugs, tasks and decisions for this feature.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{feature.bugs.map((bug) => (
|
||||
<div key={bug.id} className="flex items-center justify-between p-3 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug size={14} className="text-destructive" />
|
||||
<span className="text-sm font-medium">{bug.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{bug.status}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center justify-between p-3 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare size={14} className="text-blue-500" />
|
||||
<span className="text-sm font-medium">{task.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{task.status}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.decisions.map((decision) => (
|
||||
<div key={decision.id} className="flex items-center justify-between p-3 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-green-500" />
|
||||
<span className="text-sm font-medium">{decision.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{decision.status}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.bugs.length === 0 && feature.tasks.length === 0 && feature.decisions.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No linked work yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relationships" className="space-y-4">
|
||||
{featureRelationships.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No relationships yet.</p>
|
||||
) : (
|
||||
featureRelationships.map((relationship) => (
|
||||
<Card key={relationship.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{`${relationship.from} -> ${relationship.to}`}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{relationship.type} (strength {relationship.strength}) source {relationship.source}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p className="text-muted-foreground">{relationship.rationale}</p>
|
||||
<TrustPanel
|
||||
title="Relationship"
|
||||
trust={{
|
||||
source: `relationship:${relationship.source}`,
|
||||
confidence: relationship.confidence * 20,
|
||||
evidence: [{ id: relationship.id, type: "analysis", refId: relationship.id, label: relationship.type }],
|
||||
lastChecked: feature.trust.lastChecked,
|
||||
approvalState: relationship.type === "conflicts_with" ? "rejected" : "approved",
|
||||
}}
|
||||
/>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/projects/${project.id}/relationships`}>View graph context</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Build and review history</CardTitle>
|
||||
<CardDescription>History and evidence snapshots for feature-linked work.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">Snapshots</p>
|
||||
<ul className="text-muted-foreground mt-2 list-disc pl-6 space-y-1">
|
||||
{historySnapshots.map((snapshot) => (
|
||||
<li key={snapshot.id}>
|
||||
<Link
|
||||
to={`/projects/${project.id}/snapshots/${snapshot.id}`}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
{snapshot.id}
|
||||
</Link>{" "}
|
||||
- {snapshot.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{historySnapshots.length === 0 && <p className="text-muted-foreground">No snapshots linked to this feature yet.</p>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Checks</p>
|
||||
<ul className="text-muted-foreground mt-2 list-disc pl-6 space-y-1">
|
||||
{includedChecks.map((check) => (
|
||||
<li key={check.id}>
|
||||
<span>{check.title}</span> - {check.status} - {check.severity}
|
||||
</li>
|
||||
))}
|
||||
{includedChecks.length === 0 && <li>No feature checks yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Included in active context pack</p>
|
||||
<p className="text-muted-foreground">
|
||||
{includedInActivePack ? "Yes" : "No"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const showFeatureCheckLinks = (projectId: string, featureId: string, navigate: (to: string) => void) => {
|
||||
navigate(`/projects/${projectId}/checks?focus=${featureId}`);
|
||||
};
|
||||
|
||||
export default FeatureDetail;
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Box,
|
||||
FileText,
|
||||
Bug,
|
||||
CheckSquare,
|
||||
Layers,
|
||||
Link2,
|
||||
PlayCircle,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { useProjectFeatureGroupsData, useProjectInitiativesData } from "@/shared/hooks/useProjectData";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { ConfidenceBadge } from "@/components/ui/ConfidenceBadge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import RuntimeFeatureOverlay from "@/features/operations/pages/RuntimeFeatureOverlay";
|
||||
|
||||
type SectionFilter = "all" | "Todo" | "In Progress" | "Done";
|
||||
type InitiativeFilter = "all" | "initiative-linked" | string;
|
||||
|
||||
const FeatureMap = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { projects, architectureModels } = useProject();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const initiativesResource = useProjectInitiativesData(projectId, project?.initiatives ?? []);
|
||||
const initiatives = initiativesResource.data ?? project?.initiatives ?? [];
|
||||
const featureGroupsResource = useProjectFeatureGroupsData(projectId);
|
||||
const featureGroups = featureGroupsResource.data ?? project?.featureGroups ?? [];
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<SectionFilter>("all");
|
||||
const [architectureFirstFilter, setArchitectureFirstFilter] = React.useState<"all" | "from-architecture">("all");
|
||||
const [initiativeFilter, setInitiativeFilter] = React.useState<InitiativeFilter>(searchParams.get("initiativeId") ?? "all");
|
||||
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const architectureModel = architectureModels.find((arch) => arch.projectId === project.id);
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId],
|
||||
);
|
||||
};
|
||||
|
||||
const featuresForGroup = (groupId: string) => {
|
||||
const group = featureGroups.find((item) => item.id === groupId);
|
||||
if (!group) return [];
|
||||
return group.features
|
||||
.filter((feature) =>
|
||||
architectureFirstFilter === "all" ? true : (feature.trust.source ?? "").toLowerCase().includes("architecture")
|
||||
|| feature.trust.source.toLowerCase().includes("import")
|
||||
)
|
||||
.filter((feature) => (statusFilter === "all" ? true : feature.status === statusFilter))
|
||||
.filter((feature) => {
|
||||
if (initiativeFilter === "all") return true;
|
||||
if (initiativeFilter === "initiative-linked") return Boolean(feature.initiativeId);
|
||||
return feature.initiativeId === initiativeFilter;
|
||||
})
|
||||
.filter((feature) => feature.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
|
||||
const totalFeatureCount = featureGroups.reduce((acc, group) => acc + group.features.length, 0);
|
||||
const initiativeLinkedCount = featureGroups.reduce((acc, group) => acc + group.features.filter((feature) => Boolean(feature.initiativeId)).length, 0);
|
||||
const selectedInitiative = initiatives.find((initiative) => initiative.id === initiativeFilter);
|
||||
const linkedEvidenceCount = featureGroups.reduce((acc, group) => {
|
||||
const current = group.features.reduce((facc, feature) => facc + feature.codeEvidence.length, 0);
|
||||
return acc + current;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Feature Map"
|
||||
subtitle="Workspace-first feature topology with context-ready actions."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feature Map Controls</CardTitle>
|
||||
<CardDescription>Filters are feature-group aware and trust-aware.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search feature name..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Filter features by status"
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as SectionFilter)}
|
||||
>
|
||||
<option value="all">All status</option>
|
||||
<option value="Todo">Todo</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Done">Done</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filter features by architecture source"
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={architectureFirstFilter}
|
||||
onChange={(e) => setArchitectureFirstFilter(e.target.value as "all" | "from-architecture")}
|
||||
>
|
||||
<option value="all">All features</option>
|
||||
<option value="from-architecture">Discovered from architecture</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filter features by initiative"
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={initiativeFilter}
|
||||
onChange={(e) => setInitiativeFilter(e.target.value as InitiativeFilter)}
|
||||
>
|
||||
<option value="all">All initiatives</option>
|
||||
<option value="initiative-linked">Initiative-linked only</option>
|
||||
{initiatives.map((initiative) => (
|
||||
<option key={initiative.id} value={initiative.id}>{initiative.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedInitiative ? (
|
||||
<Card className="border-emerald-500/30 bg-emerald-500/5">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Filtered by initiative: {selectedInitiative.title}</p>
|
||||
<p className="text-muted-foreground">Materialized review candidates from this roadmap lane appear here after Review Queue acceptance.</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/initiatives/${selectedInitiative.id}`)}>Open initiative detail</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Feature data source</p>
|
||||
<p className="text-muted-foreground">
|
||||
{projectBrainClientMode === "real"
|
||||
? "Live data mode loads Feature Map and evidence from the backend."
|
||||
: "Local data mode uses the local fixture-backed ProjectContext data."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={projectBrainClientMode === "real" ? "default" : "secondary"}>
|
||||
{projectBrainClientMode === "real" ? "Live data" : "Local data"}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={featureGroupsResource.reload}>
|
||||
<RefreshCw size={14} className="mr-2" /> Reload features
|
||||
</Button>
|
||||
</div>
|
||||
{featureGroupsResource.error && (
|
||||
<p className="basis-full text-xs text-destructive">
|
||||
Feature data read failed: {featureGroupsResource.error.message}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Total groups</p>
|
||||
<p className="text-xl font-bold mt-1">{featureGroups.length}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Total features</p>
|
||||
<p className="text-xl font-bold mt-1">{totalFeatureCount}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Evidence entries</p>
|
||||
<p className="text-xl font-bold mt-1">{linkedEvidenceCount}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Initiative-linked</p>
|
||||
<p className="text-xl font-bold mt-1">{initiativeLinkedCount}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Architecture run</p>
|
||||
<p className="text-xl font-bold mt-1">
|
||||
{architectureModel ? `active` : "missing"}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<RuntimeFeatureOverlay projectId={project.id} compact />
|
||||
|
||||
<div className="space-y-6">
|
||||
{featureGroups.map((group) => {
|
||||
const isExpanded = expandedGroups.includes(group.id);
|
||||
const visibleFeatures = featuresForGroup(group.id);
|
||||
return (
|
||||
<Card key={group.id} className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center gap-3 text-left"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<Box size={20} className="text-primary" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">{group.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{group.features.length} features
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{(isExpanded || search || statusFilter !== "all" || architectureFirstFilter !== "all" || initiativeFilter !== "all") && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{visibleFeatures.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No matching features.</p>
|
||||
)}
|
||||
{visibleFeatures.map((feature) => (
|
||||
<Card key={feature.id} className="hover:shadow-md transition-shadow h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={18} className="text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-base font-medium">{feature.name}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<ConfidenceBadge confidence={feature.confidence} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<StatusBadge status={feature.status} />
|
||||
<div className="text-xs text-muted-foreground flex flex-wrap items-center gap-2">
|
||||
<span>Trust: {feature.trust.approvalState}</span>
|
||||
<span>•</span>
|
||||
<span>Confidence: {feature.trust.confidence}%</span>
|
||||
{feature.initiativeId ? <Badge variant="secondary">Initiative</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<Bug size={12} /> {feature.bugs.length} bugs
|
||||
<CheckSquare size={12} className="ml-2" /> {feature.tasks.length} tasks
|
||||
<Layers size={12} className="ml-2" /> evidence {feature.codeEvidence.length}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => navigate(`/projects/${project.id}/features/${feature.id}`)}
|
||||
>
|
||||
<Link2 size={12} /> Open detail
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${project.id}/context-packs?source=feature&featureId=${feature.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlayCircle size={12} /> Open context pack setup
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureMap;
|
||||
@@ -0,0 +1,817 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Archive, ClipboardCopy, Download, FileArchive, FileCode2, GitBranch, MessageCircleWarning, MessageSquareText, PackageCheck, ShieldCheck, Sparkles, Table2, TerminalSquare, Workflow } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Textarea } from "@/components/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { BuildLoopActionRail } from "@/shared/components/BuildLoopActionRail";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { ActionGroup } from "@/shared/components/ActionGroup";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import type { CodexHandoff, HandoffReviewDecision } from "@/shared/lib/model";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { realClient, type RealHandoffBundle } from "@/shared/api/realClient";
|
||||
import { useProjectCodexTasksData, useProjectHandoffBundlesData } from "@/shared/hooks/useProjectData";
|
||||
import { formatCodexHandoffMarkdown } from "@/shared/lib/codexHandoff";
|
||||
import { formatHandoffReviewMarkdown } from "@/shared/lib/handoffReview";
|
||||
import { getSkillNames } from "@/shared/lib/codexSkills";
|
||||
import { getBuildLoopModuleExplanation } from "@/shared/lib/buildLoopWalkthrough";
|
||||
import { formatCodexSessionPackMarkdown } from "@/shared/lib/codexSessionPack";
|
||||
import { downloadCodexSessionPackBundle, downloadCodexSessionPackZip } from "@/shared/lib/codexSessionPackExport";
|
||||
import { showSuccess } from "@/utils/toast";
|
||||
|
||||
const downloadText = (filename: string, content: string) => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const scoreClass = (score: number) =>
|
||||
score >= 90 ? "border-emerald-400 bg-emerald-50/60" :
|
||||
score >= 75 ? "border-blue-300 bg-blue-50/50" :
|
||||
score >= 55 ? "border-amber-300 bg-amber-50/50" :
|
||||
"border-red-300 bg-red-50/50";
|
||||
|
||||
const handoffStage = (handoff: CodexHandoff, hasReview: boolean, hasFeedback: boolean) => {
|
||||
if (hasFeedback) return "feedback captured";
|
||||
if (hasReview) return "ready to run";
|
||||
if (handoff.qualityScore.score >= 75) return "review impact";
|
||||
return "improve prompt";
|
||||
};
|
||||
|
||||
const liveBundleTargetLabel: Record<RealHandoffBundle["target"], string> = {
|
||||
"context-pack": "Context pack",
|
||||
"codex-task": "Codex task",
|
||||
"current-state": "Current state",
|
||||
};
|
||||
|
||||
const CodexHandoffs = () => {
|
||||
const { projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { findProjectById, generateCodexHandoff, archiveCodexHandoff, generateCodexSessionPack, createBrowserBridgeSession, generateHandoffReview, updateHandoffReviewDecision, archiveHandoffReview } = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
const isRealMode = projectBrainClientMode === "real";
|
||||
const liveBundles = useProjectHandoffBundlesData(projectId);
|
||||
const liveCodexTasks = useProjectCodexTasksData(projectId);
|
||||
const handoffs = useMemo(() => (project?.codexHandoffs ?? []).filter((handoff) => !handoff.archived), [project]);
|
||||
const [selectedId, setSelectedId] = useState(handoffs[0]?.id ?? "");
|
||||
const [reviewerNotes, setReviewerNotes] = useState("");
|
||||
const selected = handoffs.find((handoff) => handoff.id === selectedId) ?? handoffs[0];
|
||||
|
||||
if (!project || !projectId) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const repoGaps = (project.repositoryIntakes ?? [])
|
||||
.filter((intake) => intake.status !== "archived")
|
||||
.flatMap((intake) => intake.gaps.map((gap) => ({ ...gap, intakeTitle: intake.title })));
|
||||
const deliveryPackages = (project.repoDeliveryPackages ?? []).filter((item) => item.status !== "archived");
|
||||
const workPlans = (project.repoWorkPlans ?? []).filter((item) => item.status !== "archived");
|
||||
const snapshots = (project.repoStateSnapshots ?? []).filter((item) => item.status !== "archived");
|
||||
const selectedReview = selected ? (project.handoffReviews ?? []).find((review) => review.handoffId === selected.id && !review.archived) : undefined;
|
||||
const feedbackItems = (project.codexExecutionFeedback ?? []).filter((feedback) => !feedback.archived);
|
||||
const selectedFeedback = selected ? feedbackItems.find((feedback) => feedback.linkedHandoffId === selected.id) : undefined;
|
||||
const sessionPacks = (project.codexSessionPacks ?? []).filter((pack) => !pack.archived);
|
||||
const selectedSessionPack = selected ? sessionPacks.find((pack) => pack.linkedHandoffId === selected.id) : undefined;
|
||||
const moduleExplanation = getBuildLoopModuleExplanation("codex-handoffs");
|
||||
const selectedReviewApproved = selectedReview?.decision === "approved";
|
||||
const selectedQualityFactors = selected ? [
|
||||
{
|
||||
label: "Source scope",
|
||||
state: selected.sourceType && selected.source.id ? "ready" : "missing",
|
||||
detail: selected.sourceType && selected.source.id ? `${selected.sourceType} source is linked.` : "Link a delivery package, work plan, repo snapshot or repository gap.",
|
||||
},
|
||||
{
|
||||
label: "Allowed files",
|
||||
state: selected.allowedFiles.length ? "ready" : "missing",
|
||||
detail: selected.allowedFiles.length ? `${selected.allowedFiles.length} allowed file(s) constrain Codex.` : "Add allowed files so Codex has a clear edit boundary.",
|
||||
},
|
||||
{
|
||||
label: "Validation matrix",
|
||||
state: selected.validationMatrix.length ? "ready" : "missing",
|
||||
detail: selected.validationMatrix.length ? `${selected.validationMatrix.length} command(s) must be reported back.` : "Add exact validation commands before execution.",
|
||||
},
|
||||
{
|
||||
label: "Rollback plan",
|
||||
state: selected.rollbackPlan.length ? "ready" : "missing",
|
||||
detail: selected.rollbackPlan.length ? `${selected.rollbackPlan.length} rollback step(s) are included.` : "Add rollback notes for risky changes.",
|
||||
},
|
||||
{
|
||||
label: "Impact review",
|
||||
state: selectedReviewApproved ? "ready" : selectedReview ? "needs review" : "missing",
|
||||
detail: selectedReviewApproved ? "Impact review is approved." : selectedReview ? `Impact review decision is ${selectedReview.decision}.` : "Generate and approve impact review before running.",
|
||||
},
|
||||
{
|
||||
label: "Feedback path",
|
||||
state: selectedSessionPack ? "ready" : "missing",
|
||||
detail: selectedSessionPack ? "Session pack includes feedback template and validation plan." : "Create a session pack before running Codex.",
|
||||
},
|
||||
] : [];
|
||||
|
||||
const createHandoff = (sourceType: CodexHandoff["sourceType"], sourceId: string) => {
|
||||
const handoff = generateCodexHandoff(project.id, { sourceType, sourceId });
|
||||
if (handoff) {
|
||||
setSelectedId(handoff.id);
|
||||
showSuccess("Codex handoff generated");
|
||||
}
|
||||
};
|
||||
|
||||
const copyHandoff = async (handoff: CodexHandoff | undefined = selected) => {
|
||||
if (!handoff) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(handoff.promptText);
|
||||
showSuccess("Codex prompt copied");
|
||||
} catch {
|
||||
showSuccess("Prompt prepared; clipboard unavailable");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadHandoff = (handoff: CodexHandoff | undefined = selected) => {
|
||||
if (!handoff) return;
|
||||
downloadText(`${project.id}-${handoff.id}.md`, formatCodexHandoffMarkdown(handoff));
|
||||
showSuccess("Codex handoff downloaded");
|
||||
};
|
||||
|
||||
const createSessionPack = () => {
|
||||
if (!selected) return;
|
||||
const pack = generateCodexSessionPack(project.id, selected.id);
|
||||
if (pack) showSuccess("Session pack created");
|
||||
};
|
||||
|
||||
const copySessionPack = async () => {
|
||||
const pack = selectedSessionPack ?? (selected ? generateCodexSessionPack(project.id, selected.id) : undefined);
|
||||
if (!pack) return;
|
||||
await navigator.clipboard?.writeText(formatCodexSessionPackMarkdown(pack));
|
||||
showSuccess("Session pack copied");
|
||||
};
|
||||
|
||||
const downloadSessionPack = () => {
|
||||
const pack = selectedSessionPack ?? (selected ? generateCodexSessionPack(project.id, selected.id) : undefined);
|
||||
if (!pack) return;
|
||||
downloadText(`${project.id}-${pack.id}.md`, formatCodexSessionPackMarkdown(pack));
|
||||
showSuccess("Session pack downloaded");
|
||||
};
|
||||
|
||||
const downloadSessionBundle = () => {
|
||||
const pack = selectedSessionPack ?? (selected ? generateCodexSessionPack(project.id, selected.id) : undefined);
|
||||
if (!pack) return;
|
||||
downloadCodexSessionPackBundle(pack);
|
||||
showSuccess("Session pack files exported");
|
||||
};
|
||||
|
||||
const downloadSessionZip = () => {
|
||||
const pack = selectedSessionPack ?? (selected ? generateCodexSessionPack(project.id, selected.id) : undefined);
|
||||
if (!pack) return;
|
||||
downloadCodexSessionPackZip(pack);
|
||||
showSuccess("Session pack ZIP downloaded");
|
||||
};
|
||||
|
||||
const runViaBrowserBridge = () => {
|
||||
const pack = selectedSessionPack ?? (selected ? generateCodexSessionPack(project.id, selected.id) : undefined);
|
||||
if (!pack) return;
|
||||
const session = createBrowserBridgeSession(project.id, pack.id, "chatgpt");
|
||||
if (session) {
|
||||
showSuccess("Browser bridge prepared from handoff");
|
||||
navigate(`/projects/${project.id}/browser-bridge`);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveSelected = () => {
|
||||
if (!selected) return;
|
||||
archiveCodexHandoff(project.id, selected.id);
|
||||
setSelectedId("");
|
||||
showSuccess("Codex handoff archived");
|
||||
};
|
||||
|
||||
const refreshImpactReview = () => {
|
||||
if (!selected) return;
|
||||
const review = generateHandoffReview(project.id, selected.id);
|
||||
if (review) {
|
||||
setReviewerNotes(review.reviewerNotes);
|
||||
showSuccess("Impact review generated");
|
||||
}
|
||||
};
|
||||
|
||||
const copyImpactReview = async () => {
|
||||
if (!selectedReview) return;
|
||||
await navigator.clipboard?.writeText(formatHandoffReviewMarkdown(selectedReview));
|
||||
showSuccess("Impact review copied");
|
||||
};
|
||||
|
||||
const downloadImpactReview = () => {
|
||||
if (!selectedReview) return;
|
||||
downloadText(`${project.id}-${selectedReview.id}.md`, formatHandoffReviewMarkdown(selectedReview));
|
||||
showSuccess("Impact review downloaded");
|
||||
};
|
||||
|
||||
const decideReview = (decision: HandoffReviewDecision) => {
|
||||
if (!selectedReview) return;
|
||||
updateHandoffReviewDecision(project.id, selectedReview.id, decision, reviewerNotes);
|
||||
showSuccess(`Impact review marked ${decision}`);
|
||||
};
|
||||
|
||||
const averageScore = handoffs.length ? Math.round(handoffs.reduce((sum, item) => sum + item.qualityScore.score, 0) / handoffs.length) : 0;
|
||||
|
||||
const createLiveBackendHandoff = async () => {
|
||||
if (!projectId) return;
|
||||
const response = await realClient.runtimeV7.createHandoffBundle(projectId, {
|
||||
target: "codex-task",
|
||||
title: `Codex handoff for ${project.name}`,
|
||||
includeAcceptedTruth: true,
|
||||
includeProjectUpdates: true,
|
||||
includeOperatingLog: true,
|
||||
autoSelectContext: true,
|
||||
selectionIntent: "Prepare the next Codex implementation handoff from accepted truth, project updates and operating log.",
|
||||
});
|
||||
await liveBundles.reload();
|
||||
showSuccess(`Live backend handoff created: ${response.data.title}`);
|
||||
};
|
||||
|
||||
const createLiveCodexTask = async (bundle: RealHandoffBundle) => {
|
||||
if (!projectId) return;
|
||||
const response = await realClient.runtimeV7.createCodexTaskFromHandoff(projectId, bundle.id, {
|
||||
goal: `Implement ${bundle.title}`,
|
||||
validationCommands: ["pnpm exec tsc --noEmit", "pnpm run lint", "pnpm run build", "pnpm run audit:all"],
|
||||
autoSelectContext: true,
|
||||
selectionIntent: bundle.title,
|
||||
});
|
||||
await liveBundles.reload();
|
||||
await liveCodexTasks.reload();
|
||||
showSuccess(`Live Codex task created: ${response.data.goal}`);
|
||||
};
|
||||
|
||||
const copyLiveHandoff = async (bundle: RealHandoffBundle) => {
|
||||
await navigator.clipboard?.writeText(bundle.markdown);
|
||||
showSuccess("Live backend handoff copied");
|
||||
};
|
||||
|
||||
const downloadLiveHandoff = (bundle: RealHandoffBundle) => {
|
||||
downloadText(`${project.id}-${bundle.id}.md`, bundle.markdown);
|
||||
showSuccess("Live backend handoff downloaded");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Codex Handoffs"
|
||||
subtitle="Controlled build instructions with allowed files, forbidden changes, validation matrix, rollback, skill instructions and required report format."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)}>
|
||||
<MessageCircleWarning size={14} className="mr-2" />Execution feedback
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
<Workflow size={14} className="mr-2" />View in Build Loop
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/repo-builder`)}>
|
||||
<PackageCheck size={14} className="mr-2" />Open Repo Builder
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button size="sm" variant="outline" onClick={() => copyHandoff()}>
|
||||
<ClipboardCopy size={14} className="mr-2" />Copy next Codex prompt
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/repo-builder`)}>
|
||||
<TerminalSquare size={14} className="mr-2" />Prepare handoff source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<BuildLoopActionRail
|
||||
projectId={project.id}
|
||||
activeStep="handoff"
|
||||
handoffCount={(isRealMode ? liveBundles.data?.length ?? 0 : handoffs.length)}
|
||||
sessionPackCount={sessionPacks.length}
|
||||
feedbackCount={feedbackItems.length}
|
||||
reviewCount={(project.handoffReviews ?? []).filter((review) => !review.archived).length}
|
||||
title="Prepare the next shippable Codex slice"
|
||||
description="Create the handoff, review the expected impact, package it for Codex, then bring the result back through Execution Feedback and Review Queue."
|
||||
/>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-5">
|
||||
<Card className="border-l-4 border-l-primary"><CardHeader className="pb-2"><CardDescription>Active handoffs</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{handoffs.length}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Average quality</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{averageScore}%</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Warnings</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{handoffs.reduce((sum, item) => sum + item.warnings.length, 0)}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Impact reviews</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{(project.handoffReviews ?? []).filter((review) => !review.archived).length}</CardContent></Card>
|
||||
<Card><CardHeader className="pb-2"><CardDescription>Feedback records</CardDescription></CardHeader><CardContent className="text-3xl font-semibold">{feedbackItems.length}</CardContent></Card>
|
||||
</section>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>Live backend handoff path</CardTitle>
|
||||
<CardDescription>
|
||||
Real-mode bridge for release proof: create persisted backend handoff bundles and Codex tasks from accepted truth, project updates and operating log.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={isRealMode ? "default" : "outline"}>{isRealMode ? "Live data" : "Local planning"}</Badge>
|
||||
{isRealMode ? <Button size="sm" variant="outline" onClick={() => liveBundles.reload()}>Reload live bundles</Button> : null}
|
||||
{isRealMode ? <Button size="sm" onClick={createLiveBackendHandoff}>Create live handoff</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{!isRealMode ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-muted-foreground">
|
||||
Local planning mode keeps rich repo-builder handoffs in browser/project state. Switch `VITE_PROJECTBRAIN_CLIENT_MODE=real` to use persisted backend handoff bundles.
|
||||
</div>
|
||||
) : liveBundles.isLoading ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-4 text-muted-foreground">Loading live backend handoffs...</div>
|
||||
) : liveBundles.error ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-destructive">{liveBundles.error.message}</div>
|
||||
) : (liveBundles.data ?? []).length ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{(liveBundles.data ?? []).slice(0, 6).map((bundle) => (
|
||||
<div key={bundle.id} className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold">{bundle.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{liveBundleTargetLabel[bundle.target]} | {bundle.status} | {bundle.approvalState}</p>
|
||||
</div>
|
||||
<Badge>{bundle.qualityScore}%</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<span>{bundle.sourceTruthItemIds.length} truth</span>
|
||||
<span>{bundle.sourceProjectUpdateIds.length} updates</span>
|
||||
<span>{bundle.sourceOperatingLogEntryIds.length} logs</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => copyLiveHandoff(bundle)}><ClipboardCopy size={14} className="mr-2" />Copy</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadLiveHandoff(bundle)}><Download size={14} className="mr-2" />Download</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => createLiveCodexTask(bundle)}><TerminalSquare size={14} className="mr-2" />Create Codex task</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-muted-foreground">
|
||||
No live backend handoff bundles yet. Create one after accepting review items or after generating project updates.
|
||||
</div>
|
||||
)}
|
||||
{isRealMode ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{liveBundles.data?.length ?? 0} live bundle(s)</Badge>
|
||||
<Badge variant="outline">{liveCodexTasks.data?.length ?? 0} live Codex task(s)</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/40">
|
||||
<CardHeader>
|
||||
<CardTitle>{moduleExplanation.title}</CardTitle>
|
||||
<CardDescription>
|
||||
Use this when: {moduleExplanation.useWhen} Outputs: {moduleExplanation.outputs} Next step: {moduleExplanation.nextStep}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-4">
|
||||
{[
|
||||
["Create handoff", handoffs.length ? "ready" : "next"],
|
||||
["Review impact", (project.handoffReviews ?? []).some((review) => !review.archived) ? "ready" : "next"],
|
||||
[sessionPacks.length ? "Session pack ready" : "Prepare session pack", sessionPacks.length ? "ready" : "next"],
|
||||
["Run in Codex", (project.handoffReviews ?? []).some((review) => !review.archived && review.decision === "approved") && sessionPacks.length ? "ready" : "blocked"],
|
||||
["Add feedback", feedbackItems.length ? "ready" : "next"],
|
||||
].map(([label, state]) => (
|
||||
<div key={label} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<Badge variant={state === "ready" ? "secondary" : state === "blocked" ? "outline" : "default"}>{state}</Badge>
|
||||
<p className="mt-2 font-semibold">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed bg-muted/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Handoff quality gate</CardTitle>
|
||||
<CardDescription>
|
||||
A handoff is ready for Codex only after source scope, impact review, validation commands, rollback and feedback capture are visible.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-5">
|
||||
{[
|
||||
["1. Source", deliveryPackages.length || repoGaps.length || workPlans.length || snapshots.length ? "ready" : "needed"],
|
||||
["2. Prompt", selected ? "ready" : "needed"],
|
||||
["3. Impact review", selectedReview ? selectedReview.decision : "next"],
|
||||
["4. Session pack", selectedSessionPack ? "ready" : "next"],
|
||||
["5. Feedback", selectedFeedback ? "captured" : "after run"],
|
||||
].map(([label, state]) => (
|
||||
<div key={label} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<Badge variant={state === "ready" || state === "captured" || state === "approved" ? "secondary" : "outline"}>{state}</Badge>
|
||||
<p className="mt-2 font-semibold">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Production handoff preflight</CardTitle>
|
||||
<CardDescription>
|
||||
Before running Codex, confirm the selected handoff has a bounded task, approved or consciously-reviewed impact, runnable validation, rollback notes, a session pack and a feedback route.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-3 xl:grid-cols-6">
|
||||
{[
|
||||
["Prompt", selected ? "ready" : "missing", selected?.title ?? "Select or generate a handoff."],
|
||||
["Allowed files", selected?.allowedFiles.length ? "bounded" : "missing", selected ? `${selected.allowedFiles.length} file(s)` : "No selected handoff."],
|
||||
["Impact review", selectedReviewApproved ? "approved" : selectedReview ? "reviewed" : "missing", selectedReview ? selectedReview.decision : "Generate review before execution."],
|
||||
["Validation", selected?.validationMatrix.length ? "ready" : "missing", selected ? `${selected.validationMatrix.length} command(s)` : "No selected handoff."],
|
||||
["Session pack", selectedSessionPack ? "ready" : "needed", selectedSessionPack ? selectedSessionPack.title : "Create pack before running."],
|
||||
["Feedback route", selectedFeedback ? "captured" : "ready", selectedFeedback ? selectedFeedback.evidenceStatus : "Use Execution Feedback after the run."],
|
||||
].map(([label, state, detail]) => (
|
||||
<div key={label} className="rounded-lg border bg-background p-3 text-sm">
|
||||
<Badge variant={state === "ready" || state === "bounded" || state === "approved" || state === "captured" ? "secondary" : "outline"}>{state}</Badge>
|
||||
<p className="mt-2 font-semibold">{label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle>Handoff trust checklist</CardTitle>
|
||||
<CardDescription>
|
||||
Quality score factors are source scope, allowed files, validation matrix, rollback plan, approved impact review and feedback path. The Handoff impact review is a simulated impact table - not a live git diff. Primary path: copy prompt, review impact, create the pack, then capture feedback. Download instructions/scripts ZIP and other exports stay secondary; Risk actions stay separated.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-3">
|
||||
{[
|
||||
["Scope first", "Choose a delivery package, work plan, repo snapshot or repository gap before generating the prompt."],
|
||||
["Review before running", "Generate and approve impact review before using the prompt in Codex."],
|
||||
["Draft session pack warning", "Early packs can expose safeguards, but do not run Codex from an unapproved pack."],
|
||||
].map(([label, detail]) => (
|
||||
<div key={label} className="rounded-md border bg-background p-3 text-sm">
|
||||
<p className="font-semibold">{label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Sparkles size={18} /> Generate handoff</CardTitle>
|
||||
<CardDescription>Create a controlled Codex build instruction from a delivery package, work plan, repo state snapshot or repository gap.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-2">
|
||||
{deliveryPackages.slice(0, 4).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => createHandoff("delivery-package", item.id)} className="rounded-lg border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex items-start justify-between gap-2"><p className="font-semibold">{item.title}</p><Badge>delivery</Badge></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.fileManifest.length} files - {item.validationCommands.length} validation commands</p>
|
||||
</button>
|
||||
))}
|
||||
{workPlans.slice(0, 3).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => createHandoff("work-plan", item.id)} className="rounded-lg border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex items-start justify-between gap-2"><p className="font-semibold">{item.title}</p><Badge variant="secondary">work plan</Badge></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.roadmapItems.length} roadmap items - next {item.recommendedNextPackageId ?? "not set"}</p>
|
||||
</button>
|
||||
))}
|
||||
{snapshots.slice(0, 2).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => createHandoff("repo-state-snapshot", item.id)} className="rounded-lg border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex items-start justify-between gap-2"><p className="font-semibold">{item.title}</p><Badge variant="outline">repo state</Badge></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.plannedFiles.length} files - {item.gaps.length} gaps</p>
|
||||
</button>
|
||||
))}
|
||||
{repoGaps.slice(0, 6).map((gap) => (
|
||||
<button key={gap.id} type="button" onClick={() => createHandoff("repo-gap", gap.id)} className="rounded-lg border bg-background p-3 text-left transition hover:border-primary/50">
|
||||
<div className="flex items-start justify-between gap-2"><p className="font-semibold">{gap.title}</p><Badge variant={gap.severity === "blocking" ? "destructive" : "outline"}>{gap.severity}</Badge></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{gap.intakeTitle} - imported evidence boundary</p>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><TerminalSquare size={18} /> Existing handoffs</CardTitle>
|
||||
<CardDescription>Select the safest next Codex prompt.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{handoffs.length ? handoffs.map((handoff) => (
|
||||
<button key={handoff.id} type="button" onClick={() => setSelectedId(handoff.id)} className={`w-full rounded-lg border p-3 text-left transition hover:border-primary/50 ${selected?.id === handoff.id ? "border-primary" : "bg-background"}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-semibold">{handoff.title}</p>
|
||||
<Badge>{handoff.qualityScore.score}%</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{handoff.sourceType} - {handoff.allowedFiles.length} files - {handoff.warnings.length} warnings</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{(project.handoffReviews ?? []).some((review) => review.handoffId === handoff.id && !review.archived) ? "impact review ready" : "no impact review"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{feedbackItems.find((feedback) => feedback.linkedHandoffId === handoff.id)?.evidenceStatus ?? "no feedback"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{sessionPacks.find((pack) => pack.linkedHandoffId === handoff.id) ? "session pack ready" : "no session pack"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-primary">
|
||||
Stage: {handoffStage(
|
||||
handoff,
|
||||
(project.handoffReviews ?? []).some((review) => review.handoffId === handoff.id && !review.archived),
|
||||
feedbackItems.some((feedback) => feedback.linkedHandoffId === handoff.id),
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="space-y-3 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<p>No handoff yet. Create a delivery package in Repo Builder, then create a Codex handoff, review impact, run it in Codex, and add execution feedback.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/repo-builder`)}>
|
||||
Open Repo Builder
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/build-loop`)}>
|
||||
View build-loop step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<section className="space-y-4">
|
||||
<Card className={scoreClass(selected.qualityScore.score)}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selected.title}</CardTitle>
|
||||
<CardDescription>{selected.objective}</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit">{selected.qualityScore.score}% - {selected.qualityScore.grade}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{selected.sourceType}</Badge>
|
||||
{getSkillNames(selected.linkedSkillIds).slice(0, 8).map((name) => <Badge key={name} variant="secondary">{name}</Badge>)}
|
||||
</div>
|
||||
{selected.warnings.length ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-background p-3 text-amber-800">
|
||||
<p className="font-semibold">Warnings</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">{selected.warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<Card className="bg-background/80">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Quality score factors</CardTitle>
|
||||
<CardDescription>
|
||||
This explains why the handoff scores {selected.qualityScore.score}% and what to fix before using it as Codex input.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedQualityFactors.map((factor) => (
|
||||
<div key={factor.label} className="rounded-md border bg-background p-3">
|
||||
<Badge variant={factor.state === "ready" ? "secondary" : factor.state === "missing" ? "destructive" : "outline"}>{factor.state}</Badge>
|
||||
<p className="mt-2 text-sm font-semibold">{factor.label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{factor.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!selectedReviewApproved ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50/80 p-3 text-sm text-amber-900">
|
||||
<p className="font-semibold">Draft session pack warning</p>
|
||||
<p className="mt-1">
|
||||
You can create a session pack early to inspect safeguards, but treat it as draft until the impact review is approved. Do not run Codex from an unapproved pack.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ActionGroup
|
||||
primary={
|
||||
<>
|
||||
<Button size="sm" onClick={() => copyHandoff(selected)} aria-label={`Copy Codex prompt for ${selected.title}`}>
|
||||
<ClipboardCopy size={14} className="mr-2" />Copy prompt
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={refreshImpactReview} aria-label={`${selectedReview ? "Refresh" : "Generate"} impact review for ${selected.title}`}>
|
||||
<Table2 size={14} className="mr-2" />{selectedReview ? "Refresh impact review" : "Generate impact review"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={createSessionPack} aria-label={`${selectedSessionPack ? "Refresh" : "Create"} session pack for ${selected.title}`}>
|
||||
<PackageCheck size={14} className="mr-2" />{selectedSessionPack ? "Refresh session pack" : "Create session pack"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/projects/${project.id}/execution-feedback`)} aria-label={`${selectedFeedback ? "Open" : "Add"} execution feedback for ${selected.title}`}>
|
||||
<MessageCircleWarning size={14} className="mr-2" />{selectedFeedback ? "Open feedback" : "Add execution feedback"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={runViaBrowserBridge} aria-label={`Run ${selected.title} through browser bridge`}>
|
||||
<MessageSquareText size={14} className="mr-2" />Run via Browser Bridge
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadHandoff(selected)} aria-label={`Download ${selected.title} markdown`}>
|
||||
<Download size={14} className="mr-2" />Download markdown
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!selectedSessionPack} onClick={() => navigate(`/projects/${project.id}/session-packs`)} aria-label={`Open session pack for ${selected.title}`}>
|
||||
<PackageCheck size={14} className="mr-2" />Open session pack
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={copySessionPack} aria-label={`Copy session pack for ${selected.title}`}>
|
||||
<ClipboardCopy size={14} className="mr-2" />Copy session pack
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadSessionPack} aria-label={`Download session pack markdown for ${selected.title}`}>
|
||||
<Download size={14} className="mr-2" />Download session pack
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadSessionZip} aria-label={`Download instructions and scripts ZIP for ${selected.title}`}>
|
||||
<FileArchive size={14} className="mr-2" />Download instructions/scripts ZIP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadSessionBundle} aria-label={`Export session pack files for ${selected.title}`}>
|
||||
<FileArchive size={14} className="mr-2" />Export session pack files
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
danger={
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" aria-label={`Archive ${selected.title}`}>
|
||||
<Archive size={14} className="mr-2" />Archive
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this Codex handoff?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The handoff will leave the active handoff list. Linked session packs, feedback and history remain available where they already exist.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep handoff</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={archiveSelected}>Archive handoff</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
note="Primary path: copy prompt, review impact, create the pack, then capture feedback. Exports are secondary proof and handoff artifacts."
|
||||
/>
|
||||
<div className="rounded-lg border bg-background/80 p-3 text-sm text-muted-foreground">
|
||||
A session pack is controlled input for a Codex build session, not a repository backup. Use Download ZIP for the controlled handoff package or Export session pack files to download the markdown handoff, JSON pack, manifests, validation plan, rollback plan, README and feedback template separately.
|
||||
</div>
|
||||
{selectedFeedback ? (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<p className="font-semibold">Feedback status: {selectedFeedback.evidenceStatus}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{selectedFeedback.outcome} - {selectedFeedback.changedFiles.length} claimed changed files - {selectedFeedback.validationResults.length} validation result(s)</p>
|
||||
{selectedFeedback.warnings.length ? <p className="mt-2 text-xs text-amber-800">{selectedFeedback.warnings.slice(0, 2).join(" ")}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-violet-500">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Table2 size={18} /> Handoff impact review</CardTitle>
|
||||
<CardDescription>
|
||||
This is a ProjectBrain-derived impact simulation, not a live git diff.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="w-fit">{selectedReview ? selectedReview.decision : "review needed"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
{!selectedReview ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-muted-foreground">
|
||||
Generate impact review before execution to inspect simulated files, risk, validation coverage and rollback coverage.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Highest risk</p><p className="text-xl font-semibold">{selectedReview.riskSummary.highestRisk}</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Validation</p><p className="text-xl font-semibold">{selectedReview.validationCoverage.coverageScore}%</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Rollback</p><p className="text-xl font-semibold">{selectedReview.rollbackCoverage.coverageScore}%</p></div>
|
||||
<div className="rounded-lg border bg-background p-3"><p className="text-xs text-muted-foreground">Recommended</p><p className="text-xl font-semibold">{selectedReview.recommendedReviewDecision}</p></div>
|
||||
</div>
|
||||
{selectedReview.missingSafeguards.length ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-amber-900">
|
||||
<p className="font-semibold">Missing safeguards</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">{selectedReview.missingSafeguards.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-lg border border-violet-300 bg-violet-50/70 p-3 text-violet-900">
|
||||
<p className="font-semibold">Simulated impact table - not a live git diff</p>
|
||||
<p className="mt-1">
|
||||
These file impacts are derived from the handoff source and ProjectBrain state. Verify them against actual Codex output before accepting execution evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:hidden" aria-label="Mobile impact review cards">
|
||||
{selectedReview.simulatedFileImpacts.map((impact) => (
|
||||
<div key={impact.path} className="rounded-lg border bg-background p-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 break-all font-mono font-semibold text-foreground">{impact.path}</p>
|
||||
<Badge variant={impact.riskLevel === "high" ? "destructive" : impact.riskLevel === "medium" ? "default" : "secondary"}>{impact.riskLevel}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide">Action</p>
|
||||
<p className="text-foreground">{impact.action}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide">Confidence</p>
|
||||
<p className="text-foreground">{impact.confidence}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-muted-foreground">{impact.expectedChangeSummary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden overflow-auto rounded-lg border md:block" aria-label="Desktop impact review table">
|
||||
<table className="w-full min-w-[720px] text-left text-xs">
|
||||
<thead className="bg-muted/60">
|
||||
<tr>
|
||||
<th className="p-2">File</th>
|
||||
<th className="p-2">Action</th>
|
||||
<th className="p-2">Risk</th>
|
||||
<th className="p-2">Confidence</th>
|
||||
<th className="p-2">Expected change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedReview.simulatedFileImpacts.map((impact) => (
|
||||
<tr key={impact.path} className="border-t">
|
||||
<td className="p-2 font-mono">{impact.path}</td>
|
||||
<td className="p-2">{impact.action}</td>
|
||||
<td className="p-2"><Badge variant={impact.riskLevel === "high" ? "destructive" : impact.riskLevel === "medium" ? "default" : "secondary"}>{impact.riskLevel}</Badge></td>
|
||||
<td className="p-2">{impact.confidence}%</td>
|
||||
<td className="p-2">{impact.expectedChangeSummary}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Textarea value={reviewerNotes || selectedReview.reviewerNotes} onChange={(event) => setReviewerNotes(event.target.value)} placeholder="Reviewer notes before execution..." className="min-h-24" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={copyImpactReview}><ClipboardCopy size={14} className="mr-2" />Copy review</Button>
|
||||
<Button size="sm" variant="outline" onClick={downloadImpactReview}><Download size={14} className="mr-2" />Download review</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => decideReview("approved")}>Approve</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => decideReview("needs-changes")}>Needs changes</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => decideReview("rejected")}>Reject</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Archive size={14} className="mr-2" />Archive review
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this impact review?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The impact review will leave the active review path for this handoff. Generate a new review if the handoff needs to be checked again.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep review</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => archiveHandoffReview(project.id, selectedReview.id)}>
|
||||
Archive review
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card><CardHeader><CardTitle className="text-sm">Allowed files</CardTitle></CardHeader><CardContent className="space-y-1 text-xs">{selected.allowedFiles.slice(0, 18).map((file) => <p key={file} className="font-mono">{file}</p>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-sm">Forbidden changes</CardTitle></CardHeader><CardContent className="space-y-1 text-xs">{selected.forbiddenFiles.slice(0, 10).map((file) => <p key={file}>{file}</p>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><ShieldCheck size={15} /> Validation matrix</CardTitle></CardHeader><CardContent className="space-y-1 text-xs">{selected.validationMatrix.map((item) => <p key={item.id}><code>{item.command}</code> - {item.source}</p>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><GitBranch size={15} /> Rollback</CardTitle></CardHeader><CardContent className="space-y-1 text-xs">{selected.rollbackPlan.map((item) => <p key={item}>- {item}</p>)}</CardContent></Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><FileCode2 size={18} /> Generated prompt</CardTitle>
|
||||
<CardDescription>The prompt is intentionally explicit: scope, safety, validation, rollback and report format are all included.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea readOnly value={selected.promptText} className="min-h-[520px] font-mono text-xs" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexHandoffs;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Activity, Bot, CheckCircle2, ExternalLink, GitBranch, Loader2, RefreshCw, ServerCog } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import { httpClient } from "@/shared/api/httpClient";
|
||||
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
||||
import { getActivityOperationSections, runtimeJobDetail, runtimeJobLabel, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
||||
import { summarizeHumanBlocker, summarizeTechnicalBlocker } from "@/shared/lib/operationalCopy";
|
||||
import { apiPaths } from "@projectbrain/contracts";
|
||||
|
||||
type LiveActivity = {
|
||||
id: string;
|
||||
type: "delivery" | "repository" | "project" | "operating-log" | "analysis" | "finding" | "runner-job" | "autonomy";
|
||||
title: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
actor?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
url?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
type ScopedRuntimeJob = RuntimeJob & { runId: number; labels: string[]; scope: "project" | "portfolio" };
|
||||
type ActivitySnapshot = {
|
||||
items: LiveActivity[];
|
||||
runtime: {
|
||||
availability: "live" | "stale" | "unavailable";
|
||||
warning?: string;
|
||||
runners: RuntimeRunner[];
|
||||
jobs: ScopedRuntimeJob[];
|
||||
activeJobCount: number;
|
||||
queuedJobCount: number;
|
||||
dependencyBlockedJobCount: number;
|
||||
worker?: { status: string; instanceId: string; activeJobId?: string; detail?: string; lastSeenAt: string };
|
||||
autonomy: { executor?: { status: "idle" | "working" | "degraded" | "stale"; instanceId: string; activeProjectId?: string; lastSeenAt: string; detail?: string }; executors?: Array<{ status: "idle" | "working" | "degraded" | "stale"; instanceId: string; activeProjectId?: string; lastSeenAt: string; detail?: string }>; project?: { enabled: boolean; status: string; pendingWorkCount: number; completedWorkCount: number; maxWorkItemsPerRun: number; maxRunMinutes: number; activeWorkKind?: string; blockerKind?: "human" | "retryable"; blockerReason?: string } };
|
||||
generatedAt: string;
|
||||
};
|
||||
stateVersion: string;
|
||||
pollAfterMs: number;
|
||||
};
|
||||
type UnchangedActivitySnapshot = { unchanged: true; stateVersion: string; pollAfterMs: number };
|
||||
|
||||
const failureStatuses = new Set(["failed", "failure", "denied", "cancelled"]);
|
||||
const outcomeTypes = new Set<LiveActivity["type"]>(["delivery", "project", "finding", "operating-log"]);
|
||||
const itemIcon = (type: LiveActivity["type"]) => type === "repository" || type === "runner-job" ? <GitBranch size={15} /> : type === "delivery" || type === "analysis" || type === "autonomy" ? <Bot size={15} /> : <Activity size={15} />;
|
||||
const formatTime = (value?: string) => value ? new Date(value).toLocaleString("nl-BE") : "tijd onbekend";
|
||||
|
||||
const JobRow = ({ job, compact = false }: { job: ScopedRuntimeJob; compact?: boolean }) => <div className="rounded-lg border px-3 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2"><div className="min-w-0"><p className="truncate font-semibold">{job.name}</p><p className="mt-1 text-sm text-muted-foreground">{runtimeJobDetail(job)}</p></div><Badge variant={failureStatuses.has(job.conclusion ?? job.status) ? "destructive" : "secondary"}>{runtimeJobLabel(job)}</Badge></div>
|
||||
{!compact ? <p className="mt-2 text-xs text-muted-foreground">Sinds {formatTime(job.createdAt)}{job.runnerName ? ` · uitgevoerd door ${job.runnerName}` : ""}{job.totalSteps ? ` · ${job.completedSteps}/${job.totalSteps} stappen` : ""}</p> : null}
|
||||
<a className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" href={job.url} target="_blank" rel="noreferrer">Open Gitea-bewijs <ExternalLink size={12} /></a>
|
||||
</div>;
|
||||
|
||||
export const LiveActivityStream = ({ projectId }: { projectId: string }) => {
|
||||
const [snapshot, setSnapshot] = useState<ActivitySnapshot>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [visibleItems, setVisibleItems] = useState(12);
|
||||
const pollAfterRef = useRef(15_000);
|
||||
const stateVersionRef = useRef<string | undefined>(undefined);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const pollInFlightRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
|
||||
const load = useCallback(async (silent = false) => {
|
||||
if (!projectId) return;
|
||||
const requestSequence = ++requestSequenceRef.current;
|
||||
if (!silent) setRefreshing(true);
|
||||
try {
|
||||
const query = stateVersionRef.current ? `?stateVersion=${encodeURIComponent(stateVersionRef.current)}` : "";
|
||||
const response = await httpClient.get<ActivitySnapshot | UnchangedActivitySnapshot>(`${apiPaths.projectActivityRuntime(encodeURIComponent(projectId))}${query}`);
|
||||
if (!mountedRef.current || requestSequence !== requestSequenceRef.current) return;
|
||||
pollAfterRef.current = Math.min(Math.max(response.data.pollAfterMs, 4_000), 60_000);
|
||||
if ("unchanged" in response.data) { setError(undefined); return; }
|
||||
stateVersionRef.current = response.data.stateVersion; setSnapshot(response.data); setError(undefined);
|
||||
} catch (nextError) { if (mountedRef.current && requestSequence === requestSequenceRef.current) setError(nextError instanceof Error ? nextError : new Error("Onbekende fout bij het laden van activiteit.")); }
|
||||
finally { if (mountedRef.current && requestSequence === requestSequenceRef.current) { setLoading(false); if (!silent) setRefreshing(false); } }
|
||||
}, [projectId]);
|
||||
useEffect(() => { setLoading(true); setVisibleItems(12); stateVersionRef.current = undefined; void load(); }, [load]);
|
||||
useEffect(() => {
|
||||
let stopped = false; let timer: number | undefined;
|
||||
const schedule = () => { if (!stopped) timer = window.setTimeout(tick, pollAfterRef.current); };
|
||||
const tick = async () => { if (pollInFlightRef.current) return; pollInFlightRef.current = true; try { if (document.visibilityState === "visible") await load(true); } finally { pollInFlightRef.current = false; schedule(); } };
|
||||
const onVisibilityChange = () => { if (document.visibilityState !== "visible") return; if (timer !== undefined) window.clearTimeout(timer); void tick(); };
|
||||
schedule(); document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => { stopped = true; if (timer !== undefined) window.clearTimeout(timer); document.removeEventListener("visibilitychange", onVisibilityChange); };
|
||||
}, [load]);
|
||||
|
||||
const items = snapshot?.items ?? [];
|
||||
const runtime = snapshot?.runtime;
|
||||
const projectExecutor = runtime?.autonomy.executors?.find((executor) => executor.activeProjectId === projectId) ?? runtime?.autonomy.executor;
|
||||
const projectJobs = runtime?.jobs.filter((job) => job.scope === "project") ?? [];
|
||||
const operational = getActivityOperationSections(projectJobs);
|
||||
const runnerCapacity = runtimeRunnerCapacity(runtime?.runners ?? []);
|
||||
const autoProject = runtime?.autonomy.project;
|
||||
const hasLiveExecution = operational.active.length > 0 || Boolean(autoProject?.activeWorkKind);
|
||||
const recoveryMessage = autoProject?.blockerKind === "retryable" ? summarizeTechnicalBlocker(autoProject.blockerReason) : undefined;
|
||||
const waitingMessage = operational.waitingForRunner.length ? `${operational.waitingForRunner.length} ${operational.waitingForRunner.length === 1 ? "uitvoering wacht" : "uitvoeringen wachten"} op een beschikbare runner. ProjectBrain volgt dit automatisch op.` : operational.waitingForDependency.length ? `${operational.waitingForDependency.length} ${operational.waitingForDependency.length === 1 ? "stap wacht" : "stappen wachten"} op eerdere veilige stappen.` : undefined;
|
||||
const recentOutcomes = items.filter((item) => outcomeTypes.has(item.type) && item.type !== "runner-job").slice(0, 4);
|
||||
|
||||
return <div className="space-y-5">
|
||||
<section aria-labelledby="active-executions-title" className="rounded-xl border border-primary/30 bg-card"><div className="flex flex-wrap items-start justify-between gap-3 border-b p-4"><div><h2 id="active-executions-title" className="flex items-center gap-2 text-lg font-semibold"><Bot size={18} />Actieve uitvoeringen</h2><p className="mt-1 text-sm text-muted-foreground">Wat ProjectBrain nu doet. Technische jobdetails blijven bewijs, niet de hoofdcontext.</p></div><Button size="sm" variant="outline" onClick={() => void load()} disabled={refreshing}>{refreshing ? <Loader2 size={14} className="mr-2 animate-spin" /> : <RefreshCw size={14} className="mr-2" />}Bijwerken</Button></div><div className="space-y-3 p-4" aria-live="polite">{error ? <p role="alert" className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-950">De actuele update mislukte. De laatst bevestigde toestand blijft zichtbaar.</p> : null}{loading && !snapshot ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 size={15} className="animate-spin" />Actuele uitvoering laden…</div> : null}{hasLiveExecution ? <>{autoProject?.activeWorkKind ? <div className="rounded-lg border border-primary/25 bg-primary/5 p-3"><p className="font-semibold">ProjectBrain werkt autonoom aan {autoProject.activeWorkKind}</p><p className="mt-1 text-sm text-muted-foreground">{autoProject.pendingWorkCount} werkitem(s) blijven open.</p></div> : null}{operational.active.slice(0, 3).map((job) => <JobRow key={job.id} job={job} />)}</> : !loading && snapshot ? <div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50/50 p-4"><CheckCircle2 size={17} className="mt-0.5 text-emerald-700" /><div><p className="font-semibold">Geen actieve uitvoering</p><p className="mt-1 text-sm text-muted-foreground">{waitingMessage ?? "Er draait nu geen werk voor dit project."}</p></div></div> : null}</div></section>
|
||||
|
||||
{recoveryMessage || waitingMessage ? <section className="rounded-xl border bg-card p-4"><h2 className="text-base font-semibold">Wachtend of automatisch herstel</h2>{recoveryMessage ? <p className="mt-2 text-sm text-muted-foreground">{recoveryMessage} ProjectBrain probeert dit automatisch opnieuw; je hoeft niets te doen.</p> : null}{waitingMessage ? <p className="mt-2 text-sm text-muted-foreground">{waitingMessage}</p> : null}</section> : null}
|
||||
{(operational.waitingForRunner.length || operational.waitingForDependency.length) ? <section className="rounded-xl border bg-card p-4"><h2 className="text-base font-semibold">Hierna</h2><p className="mt-1 text-sm text-muted-foreground">Een beperkte vooruitblik; geen technische workflowgrafiek.</p><div className="mt-3 space-y-2">{[...operational.waitingForRunner, ...operational.waitingForDependency].slice(0, 3).map((job) => <JobRow key={job.id} job={job} compact />)}</div></section> : null}
|
||||
|
||||
{recentOutcomes.length ? <section className="rounded-xl border bg-card p-4"><h2 className="text-base font-semibold">Recent resultaat</h2><p className="mt-1 text-sm text-muted-foreground">Betekenisvolle projectuitkomsten staan vóór afgeronde technische jobs.</p><div className="mt-3 grid gap-2 md:grid-cols-2">{recentOutcomes.map((item) => <div key={item.id} className="rounded-lg border px-3 py-3"><div className="flex items-start gap-2"><CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-700" /><div><p className="text-sm font-semibold">{item.title}</p><p className="mt-1 text-xs text-muted-foreground">{item.description}</p><p className="mt-1 text-xs text-muted-foreground">{formatTime(item.createdAt)}</p></div></div>{item.url ? <a className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" href={item.url} target="_blank" rel="noreferrer">Open bewijs <ExternalLink size={12} /></a> : null}</div>)}</div></section> : null}
|
||||
|
||||
{operational.recentCompleted.length ? <details className="rounded-xl border bg-card"><summary className="cursor-pointer list-none p-4 text-sm font-semibold">Recente technische uitvoering ({operational.recentCompleted.length})</summary><div className="space-y-2 border-t p-4">{operational.recentCompleted.slice(0, 6).map((job) => <JobRow key={job.id} job={job} compact />)}</div></details> : null}
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="flex cursor-pointer list-none items-center justify-between gap-3 p-4 text-sm font-semibold"><span>Geschiedenis{items.length ? ` (${items.length})` : ""}</span><span className="text-xs font-normal text-muted-foreground">Tijdlijn en bewijs</span></summary><div className="space-y-3 border-t p-4">{!loading && !error && items.length === 0 ? <p className="text-sm text-muted-foreground">Nog geen duurzame uitvoeringsactiviteit voor dit project.</p> : null}<div className="space-y-2" role="feed" aria-label="Projectgeschiedenis">{items.slice(0, visibleItems).map((item, index) => <article key={item.id} aria-posinset={index + 1} aria-setsize={items.length} className="flex gap-3 rounded-lg border p-3"><span className="mt-0.5 rounded-md bg-muted p-2">{itemIcon(item.type)}</span><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="font-semibold">{item.title}</p>{item.status ? <Badge variant={failureStatuses.has(item.status) ? "destructive" : "outline"}>{item.status}</Badge> : null}</div><p className="mt-1 break-words text-sm text-muted-foreground">{item.description}</p><p className="mt-1 text-xs text-muted-foreground">{formatTime(item.createdAt)}</p>{item.actor || item.branch || item.repository ? <details className="mt-2 text-xs text-muted-foreground"><summary className="cursor-pointer">Technische details</summary><p className="mt-1 break-words">{[item.repository, item.branch, item.actor].filter(Boolean).join(" · ")}</p></details> : null}</div></article>)}</div>{items.length > visibleItems ? <Button size="sm" variant="outline" onClick={() => setVisibleItems((current) => Math.min(current + 12, items.length))}>Toon 12 oudere gebeurtenissen</Button> : null}</div></details>
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl p-4"><span className="flex min-w-0 items-center gap-3"><ServerCog size={18} className="shrink-0 text-muted-foreground" /><span><span className="block font-semibold">Technische uitvoeringsdetails</span><span className="block text-xs font-normal text-muted-foreground">Runners, queues, workerstatus en ruwe jobinformatie.</span></span></span>{runtime ? <Badge variant={runtime.availability === "live" ? "secondary" : "outline"}>{runtime.availability === "live" ? "Actueel" : "Niet actueel"}</Badge> : null}</summary><div className="space-y-4 border-t p-4">{runtime?.autonomy.project ? <div className="rounded-lg border p-3"><p className="font-semibold">Auto Mode</p><p className="mt-1 text-sm text-muted-foreground">{runtime.autonomy.project.pendingWorkCount} open · {runtime.autonomy.project.completedWorkCount} afgerond{runtime.autonomy.project.blockerReason ? ` · ${runtime.autonomy.project.blockerKind === "human" ? summarizeHumanBlocker(runtime.autonomy.project.blockerReason) : summarizeTechnicalBlocker(runtime.autonomy.project.blockerReason)}` : ""}</p><p className="mt-2 text-xs text-muted-foreground">Uitvoerder: {projectExecutor && projectExecutor.status !== "stale" ? `${projectExecutor.status} · heartbeat ${formatTime(projectExecutor.lastSeenAt)}` : "niet verbonden"}</p></div> : null}<div className="grid gap-3 sm:grid-cols-3"><div className="rounded-lg border p-3"><p className="text-xs text-muted-foreground">Runners online</p><p className="text-2xl font-semibold">{runnerCapacity.online}/{runnerCapacity.total}</p></div><div className="rounded-lg border p-3"><p className="text-xs text-muted-foreground">Runners vrij</p><p className="text-2xl font-semibold">{runnerCapacity.idle}</p></div><div className="rounded-lg border p-3"><p className="text-xs text-muted-foreground">Analyseworker</p><p className="truncate text-lg font-semibold">{runtime?.worker?.status ?? "niet gemeld"}</p></div></div></div></details>
|
||||
</div>;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Link, Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ArrowRight, FileCode2, Settings2 } from "lucide-react";
|
||||
import { Button, Card, CardContent } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { LiveActivityStream } from "@/features/history/components/LiveActivityStream";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { getOperatingLogForProject } from "@/shared/lib/projectbrainSelectors";
|
||||
|
||||
const technicalQueryKeys = ["proofFilter", "entryId", "focus", "releaseEvidenceScope"];
|
||||
|
||||
const ProjectActivity = () => {
|
||||
const { projectId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { findProjectById } = useProject();
|
||||
const project = projectId ? findProjectById(projectId) : undefined;
|
||||
const logEntries = projectId ? [...(project?.starterOperatingLog ?? []), ...getOperatingLogForProject(projectId)]
|
||||
.filter((entry, index, all) => all.findIndex((candidate) => candidate.id === entry.id) === index)
|
||||
.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()) : [];
|
||||
|
||||
if (!project || !projectId) return <ProjectRouteFallback projectId={projectId} />;
|
||||
if (technicalQueryKeys.some((key) => searchParams.has(key))) return <Navigate to={`/projects/${projectId}/history-technical?${searchParams.toString()}`} replace />;
|
||||
|
||||
return <div className="space-y-6">
|
||||
<PageHeader title="Activiteit" subtitle="Eerst wat nu loopt, wacht of automatisch herstelt. Daarna pas recente resultaten en geschiedenis." />
|
||||
|
||||
{projectBrainClientMode === "real" ? <LiveActivityStream projectId={projectId} /> : <Card><CardContent className="space-y-3 p-5"><p className="font-semibold">Recente projectgeschiedenis</p><p className="text-sm text-muted-foreground">Live uitvoeringsinformatie is beschikbaar zodra dit project met de productieomgeving verbonden is.</p>{logEntries.slice(0, 10).map((entry) => <div key={entry.id} className="rounded-lg border p-3"><p className="font-medium">{entry.title}</p><p className="mt-1 text-sm text-muted-foreground">{entry.summary}</p></div>)}</CardContent></Card>}
|
||||
|
||||
<Card className="border-dashed"><CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="flex items-center gap-2 font-semibold"><Settings2 size={17} />Volledige technische geschiedenis</p><p className="mt-1 max-w-2xl text-sm text-muted-foreground">Ruwe events, bronimports, receipts, snapshots, builds, handoffs en releasebewijs blijven beschikbaar voor audit en diagnose.</p></div><Button asChild variant="outline"><Link to={`/projects/${projectId}/history-technical`}><FileCode2 size={14} />Technische geschiedenis <ArrowRight size={14} /></Link></Button></CardContent></Card>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default ProjectActivity;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Bar, BarChart, CartesianGrid, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from "recharts";
|
||||
import { Download, FileText, Gauge, ListChecks, Radar, ShieldCheck } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback";
|
||||
import { getDocumentationItemsForProject, getOperatingLogForProject, getReviewQueueItemsForProject } from "@/shared/lib/projectbrainSelectors";
|
||||
import { isRealProjectDataMode } from "@/shared/contexts/useProjectDataSource";
|
||||
import {
|
||||
useProjectChecksData,
|
||||
useProjectDocumentationItemsData,
|
||||
useProjectEvidenceData,
|
||||
useProjectKnowledgeData,
|
||||
useProjectMemoryRulesData,
|
||||
useProjectOperatingLogData,
|
||||
useProjectRelationshipsData,
|
||||
useProjectReviewQueueData,
|
||||
useProjectSnapshotsData,
|
||||
} from "@/shared/hooks/useProjectData";
|
||||
import { createRcAuditReport, createRcMarkdownReport } from "@/shared/lib/rcAudit";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
|
||||
type InsightCard = {
|
||||
title: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
link: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
approvalState: "pending" | "approved" | "rejected" | "blocked";
|
||||
};
|
||||
|
||||
const trustBadge = (state: "pending" | "approved" | "rejected" | "blocked") => {
|
||||
if (state === "approved") return "default";
|
||||
if (state === "rejected") return "destructive";
|
||||
if (state === "blocked") return "outline";
|
||||
return "secondary";
|
||||
};
|
||||
|
||||
const downloadTextFile = (filename: string, content: string) => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const copyText = async (content: string, success: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
showSuccess(success);
|
||||
} catch {
|
||||
showError("Clipboard is unavailable. Use export instead.");
|
||||
}
|
||||
};
|
||||
|
||||
const gateVariant = (status: "pass" | "warn" | "fail") =>
|
||||
status === "pass" ? "default" : status === "fail" ? "destructive" : "secondary";
|
||||
|
||||
const Insights = () => {
|
||||
const { projectId } = useParams();
|
||||
const { projects, checks: localChecks, knowledge: localKnowledge, snapshots: localSnapshots, relationships: localRelationships, evidence: localEvidence } = useProject();
|
||||
const liveChecks = useProjectChecksData(projectId);
|
||||
const liveKnowledge = useProjectKnowledgeData(projectId);
|
||||
const liveSnapshots = useProjectSnapshotsData(projectId);
|
||||
const liveRelationships = useProjectRelationshipsData(projectId);
|
||||
const liveEvidence = useProjectEvidenceData(projectId);
|
||||
const liveDocumentation = useProjectDocumentationItemsData(projectId);
|
||||
const liveReviews = useProjectReviewQueueData(projectId);
|
||||
const liveMemoryRules = useProjectMemoryRulesData(projectId);
|
||||
const liveOperatingLog = useProjectOperatingLogData(projectId);
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
if (!project) return <ProjectRouteFallback projectId={projectId} />;
|
||||
|
||||
const allFeatures = project.featureGroups.flatMap((group) => group.features);
|
||||
const checks = isRealProjectDataMode ? liveChecks.data ?? [] : localChecks;
|
||||
const knowledge = isRealProjectDataMode ? liveKnowledge.data ?? [] : localKnowledge;
|
||||
const snapshots = isRealProjectDataMode ? liveSnapshots.data ?? [] : localSnapshots;
|
||||
const relationships = isRealProjectDataMode ? liveRelationships.data ?? [] : localRelationships;
|
||||
const evidence = isRealProjectDataMode ? liveEvidence.data ?? [] : localEvidence;
|
||||
const memoryRules = isRealProjectDataMode ? liveMemoryRules.data ?? [] : project.memoryRules;
|
||||
const reviewItems = isRealProjectDataMode
|
||||
? liveReviews.data ?? []
|
||||
: [...(project.starterReviewItems ?? []), ...getReviewQueueItemsForProject(project.id)];
|
||||
const docs = isRealProjectDataMode
|
||||
? liveDocumentation.data ?? []
|
||||
: [...(project.starterDocumentationItems ?? []), ...getDocumentationItemsForProject(project.id)];
|
||||
const operatingLog = isRealProjectDataMode
|
||||
? liveOperatingLog.data ?? []
|
||||
: [...(project.starterOperatingLog ?? []), ...getOperatingLogForProject(project.id)];
|
||||
const openChecks = checks.filter((check) => check.status !== "resolved");
|
||||
const staleMemoryRules = memoryRules.filter((rule) => rule.status === "Pending").length;
|
||||
const conflictRelations = relationships.filter((relationship) => relationship.type === "conflicts_with").length;
|
||||
const verifiedFeatures = allFeatures.filter((feature) => feature.lifecycle === "verified" || feature.status === "Done").length;
|
||||
const featureEvidenceMissing = allFeatures.filter((feature) => feature.codeEvidence.length === 0).length;
|
||||
const approvedDocs = docs.filter((doc) => doc.approvalState === "approved" || doc.status === "current").length;
|
||||
const pendingReview = reviewItems.filter((item) => item.attribution.approvalState !== "approved" && item.attribution.approvalState !== "rejected").length;
|
||||
const initiativeLinkedFeatures = allFeatures.filter((feature) => Boolean(feature.initiativeId)).length;
|
||||
const rcReport = createRcAuditReport(project);
|
||||
const rcMarkdown = createRcMarkdownReport(rcReport, project.name);
|
||||
|
||||
const cards: InsightCard[] = [
|
||||
{
|
||||
title: "Coverage gaps",
|
||||
value: `${featureEvidenceMissing} feature(s)`,
|
||||
detail: "Features with no code evidence mapped.",
|
||||
link: `/projects/${project.id}/features`,
|
||||
source: "feature coverage scan",
|
||||
confidence: 84,
|
||||
approvalState: "approved",
|
||||
},
|
||||
{
|
||||
title: "Verification gaps",
|
||||
value: `${openChecks.length} open checks`,
|
||||
detail: "Pending checks still need review and inclusion.",
|
||||
link: `/projects/${project.id}/checks`,
|
||||
source: "checks pipeline",
|
||||
confidence: 90,
|
||||
approvalState: "pending",
|
||||
},
|
||||
{
|
||||
title: "Stale memory",
|
||||
value: `${staleMemoryRules} stale rule(s)`,
|
||||
detail: "Rules without verified state.",
|
||||
link: `/projects/${project.id}/memory`,
|
||||
source: "memory state scan",
|
||||
confidence: 76,
|
||||
approvalState: "pending",
|
||||
},
|
||||
{
|
||||
title: "Conflicts",
|
||||
value: `${conflictRelations} conflict link(s)`,
|
||||
detail: "Conflict relationships found in topology.",
|
||||
link: `/projects/${project.id}/relationships`,
|
||||
source: "relationship analyzer",
|
||||
confidence: 88,
|
||||
approvalState: "approved",
|
||||
},
|
||||
{
|
||||
title: "Review pressure",
|
||||
value: `${pendingReview} item(s)`,
|
||||
detail: "Candidates waiting before they can become truth.",
|
||||
link: `/projects/${project.id}/review-queue`,
|
||||
source: "review queue",
|
||||
confidence: 91,
|
||||
approvalState: pendingReview > 0 ? "pending" : "approved",
|
||||
},
|
||||
{
|
||||
title: "Initiative execution",
|
||||
value: `${initiativeLinkedFeatures} feature(s)`,
|
||||
detail: "Features already linked to roadmap initiatives.",
|
||||
link: `/projects/${project.id}/initiatives`,
|
||||
source: "roadmap analyzer",
|
||||
confidence: 86,
|
||||
approvalState: "approved",
|
||||
},
|
||||
];
|
||||
|
||||
const coverageData = [
|
||||
{ name: "Features", current: verifiedFeatures, remaining: Math.max(0, allFeatures.length - verifiedFeatures) },
|
||||
{ name: "Docs", current: approvedDocs, remaining: Math.max(0, docs.length - approvedDocs) },
|
||||
{ name: "Review", current: Math.max(0, reviewItems.length - pendingReview), remaining: pendingReview },
|
||||
{ name: "Evidence", current: evidence.length, remaining: featureEvidenceMissing },
|
||||
];
|
||||
|
||||
const reviewData = [
|
||||
{ name: "Accepted", value: reviewItems.filter((item) => item.attribution.approvalState === "approved").length },
|
||||
{ name: "Rejected", value: reviewItems.filter((item) => item.attribution.approvalState === "rejected").length },
|
||||
{ name: "Pending", value: pendingReview },
|
||||
].filter((item) => item.value > 0);
|
||||
|
||||
const readinessTrend = rcReport.sections.map((section, index) => ({
|
||||
name: section.area.replace("Technical quality", "Tech").replace("Result processing", "Results"),
|
||||
score: section.score,
|
||||
target: section.target,
|
||||
order: index + 1,
|
||||
}));
|
||||
|
||||
const reportSummary = [
|
||||
`RC score: ${rcReport.overallScore}/100`,
|
||||
`Verdict: ${rcReport.rcVerdict}`,
|
||||
`Feature coverage: ${verifiedFeatures}/${allFeatures.length}`,
|
||||
`Approved/current docs: ${approvedDocs}/${docs.length}`,
|
||||
`Pending review: ${pendingReview}`,
|
||||
`Open checks: ${openChecks.length}`,
|
||||
`Operating log entries: ${operatingLog.length}`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Insights & Reports"
|
||||
subtitle="Release-candidate intelligence, charts, evidence and exportable reporting for the whole project flow."
|
||||
actions={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => copyText(rcMarkdown, "RC report copied") }>
|
||||
<FileText size={14} /> Copy RC report
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2" onClick={() => downloadTextFile(`${project.id}-rc-report.md`, rcMarkdown)}>
|
||||
<Download size={14} /> Export report
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-primary">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">RC score</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-semibold">{rcReport.overallScore}/100</p><p className="text-xs text-muted-foreground">{rcReport.rcVerdict}</p></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Flow coverage</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-semibold">{Math.round(((verifiedFeatures + approvedDocs) / Math.max(1, allFeatures.length + docs.length)) * 100)}%</p><p className="text-xs text-muted-foreground">features + docs</p></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Review pressure</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-semibold">{pendingReview}</p><p className="text-xs text-muted-foreground">pending candidates</p></CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-sky-500">
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Report health</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-semibold">{rcReport.reportHealth.filter((item) => item.value >= item.target).length}/{rcReport.reportHealth.length}</p><p className="text-xs text-muted-foreground">signals above target</p></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="flex h-auto flex-wrap justify-start">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="charts">Charts</TabsTrigger>
|
||||
<TabsTrigger value="rc">RC Report</TabsTrigger>
|
||||
<TabsTrigger value="evidence">Evidence</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">{card.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold">{card.value}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{card.detail}</p>
|
||||
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<p>Source: {card.source}</p>
|
||||
<p>Confidence: {card.confidence}%</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Approval:</span>
|
||||
<Badge variant={trustBadge(card.approvalState)}>{card.approvalState}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={card.link} className="text-xs underline mt-3 inline-block">
|
||||
Open in context
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="charts" className="space-y-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gauge size={18} /> Coverage by artifact type</CardTitle>
|
||||
<CardDescription>Shows what is already verified/approved versus what still needs attention.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={coverageData} margin={{ top: 10, right: 16, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="current" stackId="a" fill="hsl(var(--primary))" name="Current" />
|
||||
<Bar dataKey="remaining" stackId="a" fill="hsl(var(--muted-foreground))" name="Remaining" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ListChecks size={18} /> Review distribution</CardTitle>
|
||||
<CardDescription>Review pressure across accepted, rejected and pending candidates.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
{reviewData.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Pie data={reviewData} dataKey="value" nameKey="name" innerRadius={54} outerRadius={96} label>
|
||||
{reviewData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={["hsl(var(--primary))", "hsl(var(--destructive))", "hsl(var(--muted-foreground))"][index % 3]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="No review data yet" description="Capture or generate candidates to start measuring review pressure." />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Radar size={18} /> RC readiness by area</CardTitle>
|
||||
<CardDescription>Compares current area scores with target RC thresholds.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={readinessTrend} margin={{ top: 10, right: 16, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" interval={0} angle={-20} textAnchor="end" height={80} />
|
||||
<YAxis domain={[60, 100]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="score" stroke="hsl(var(--primary))" strokeWidth={2} name="Score" />
|
||||
<Line type="monotone" dataKey="target" stroke="hsl(var(--muted-foreground))" strokeDasharray="4 4" name="Target" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rc" className="space-y-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><ShieldCheck size={18} /> Release-candidate area audit</CardTitle>
|
||||
<CardDescription>Design feel, usability, flow, robustness, reporting, charts and technical quality reviewed separately.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rcReport.sections.map((section) => (
|
||||
<div key={section.area} className="rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-semibold">{section.area}</p>
|
||||
<Badge variant={section.score >= section.target ? "default" : "secondary"}>{section.score}/{section.target}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{section.verdict}</p>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<p><span className="font-medium text-foreground">Strength:</span> {section.strengths[0]}</p>
|
||||
<p><span className="font-medium text-foreground">Risk:</span> {section.risks[0]}</p>
|
||||
<p><span className="font-medium text-foreground">V76:</span> {section.fixesApplied[0]}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>RC gates</CardTitle>
|
||||
<CardDescription>What blocks or warns before final release sign-off.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rcReport.releaseGates.map((gate) => (
|
||||
<div key={gate.label} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">{gate.label}</p>
|
||||
<Badge variant={gateVariant(gate.status)}>{gate.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{gate.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exportable RC summary</CardTitle>
|
||||
<CardDescription>Copy this to a release note, QA ticket or handoff.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{reportSummary.map((line) => <p key={line}>{line}</p>)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => copyText(rcMarkdown, "RC report copied")}>Copy full report</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadTextFile(`${project.id}-rc-report.md`, rcMarkdown)}>Download markdown</Button>
|
||||
<Button size="sm" variant="outline" asChild><Link to={`/projects/${project.id}/audit`}>Open Audit Center</Link></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="evidence" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Knowledge-backed signals</CardTitle>
|
||||
<CardDescription>Connect insights to architecture, checks and memory context.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{knowledge.filter((item) => item.projectId === project.id).slice(0, 6).map((entry) => (
|
||||
<Card key={entry.id} className="p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">{entry.title}</p>
|
||||
<Badge variant="outline">{entry.category}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{entry.summary}</p>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Source: {entry.source} • Confidence: {entry.confidence} • Last updated {entry.lastUpdated}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{knowledge.filter((item) => item.projectId === project.id).length === 0 && (
|
||||
<EmptyState title="No knowledge signals yet" description="Add knowledge items or accept reviewed documentation to strengthen the report." />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Evidence in scope</CardTitle>
|
||||
<CardDescription>Evidence links are what keep reports grounded instead of speculative.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{evidence.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="rounded border p-2 text-sm">
|
||||
<p className="font-semibold">{item.linkedEntity.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type: {item.type} • Source: {item.source} • Confidence: {item.confidence}%
|
||||
</p>
|
||||
{item.route && <Link className="text-xs underline" to={item.route}>Open linked target</Link>}
|
||||
</div>
|
||||
))}
|
||||
{evidence.length === 0 && (
|
||||
<EmptyState title="No evidence yet" description="Map code, docs or review evidence before this report can be considered release-grade." />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Insights;
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, BookOpen, CheckCircle2, Clock3, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { realClient, type ProjectKnowledgeResponse } from "@/shared/api/realClient";
|
||||
|
||||
const freshnessVariant = (freshness: ProjectKnowledgeResponse["freshness"]) => freshness === "fresh" ? "secondary" : freshness === "watch" ? "outline" : "destructive";
|
||||
const freshnessLabel = (freshness: ProjectKnowledgeResponse["freshness"]) => freshness === "fresh" ? "Actueel" : freshness === "watch" ? "Controleren" : "Verouderd";
|
||||
const authorityLabel = (authority: string) => ({ runtime: "Runtimebewijs", repository: "Repositorybewijs", verified_evidence: "Geverifieerd bewijs", approved_knowledge: "Goedgekeurde kennis", memory: "Werkgeheugen", roadmap: "Projectplan" }[authority] ?? authority);
|
||||
const sourceLabel = (source: string) => ({ generated: "gegenereerd", "generated execution snapshot": "uitvoeringssnapshot", git: "repository", repository: "repository", manual: "handmatig" }[source.toLowerCase()] ?? source);
|
||||
const typeLabel = (type: string) => ({ dna: "Project-DNA", goal: "Doel", objective: "Doel", requirement: "Requirement", constraint: "Constraint", architecture: "Architectuur", decision: "Beslissing", documentation: "Documentatie", memory: "Geheugen", context_pack: "Contextpakket", evidence: "Bewijs", roadmap: "Projectplan" }[type.toLowerCase()] ?? type.replaceAll("_", " "));
|
||||
|
||||
const truthSection = (type: string) => {
|
||||
const normalized = type.toLowerCase();
|
||||
if (normalized.includes("goal") || normalized.includes("objective") || normalized === "dna") return "Doel";
|
||||
if (normalized.includes("requirement")) return "Requirements";
|
||||
if (normalized.includes("constraint")) return "Constraints";
|
||||
if (normalized.includes("architect")) return "Architectuur";
|
||||
if (normalized.includes("decision")) return "Beslissingen";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const ProjectKnowledgeCenter = ({ projectId }: { projectId: string }) => {
|
||||
const [knowledge, setKnowledge] = useState<ProjectKnowledgeResponse>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setKnowledge((await realClient.operatingSystem.getProjectKnowledge(projectId)).data); setError(undefined); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "Project knowledge kon niet worden geladen."); }
|
||||
finally { setLoading(false); }
|
||||
}, [projectId]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const primaryTruth = useMemo(() => {
|
||||
const groups = new Map<string, ProjectKnowledgeResponse["entries"]>();
|
||||
for (const entry of knowledge?.entries ?? []) {
|
||||
const section = truthSection(entry.type);
|
||||
if (!section) continue;
|
||||
const current = groups.get(section) ?? [];
|
||||
current.push(entry);
|
||||
groups.set(section, current);
|
||||
}
|
||||
return ["Doel", "Requirements", "Constraints", "Architectuur", "Beslissingen"].map((label) => ({ label, entries: groups.get(label) ?? [] })).filter((group) => group.entries.length);
|
||||
}, [knowledge]);
|
||||
const supportingEntries = useMemo(() => (knowledge?.entries ?? []).filter((item) => !truthSection(item.type) && item.type !== "context_pack"), [knowledge]);
|
||||
const visibleEntries = showAll ? supportingEntries.slice(0, 100) : supportingEntries.slice(0, 10);
|
||||
const staleCount = (knowledge?.entries ?? []).filter((item) => item.freshness === "stale").length;
|
||||
|
||||
if (projectBrainClientMode !== "real") return null;
|
||||
return <div className="space-y-4">
|
||||
<Card className={knowledge?.unresolvedConflictCount ? "border-amber-300" : ""}>
|
||||
<CardHeader className="pb-3"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2"><BookOpen size={18} />Projectwaarheid</CardTitle><CardDescription>Wat ProjectBrain nu als de belangrijkste waarheid over doel, requirements, constraints, architectuur en beslissingen beschouwt.</CardDescription></div><Button size="sm" variant="outline" onClick={() => void load()} disabled={loading}><RefreshCw size={14} className={loading ? "animate-spin" : ""} />Vernieuwen</Button></div></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading && !knowledge ? <p className="text-sm text-muted-foreground">Projectwaarheid laden…</p> : null}
|
||||
{error ? <div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
|
||||
{knowledge ? <>
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge variant={freshnessVariant(knowledge.freshness)}>{freshnessLabel(knowledge.freshness)}</Badge><span className="text-sm text-muted-foreground">Bijgewerkt {new Date(knowledge.generatedAt).toLocaleString("nl-BE")}</span>{knowledge.unresolvedConflictCount ? <Badge variant="outline"><AlertTriangle size={12} />{knowledge.unresolvedConflictCount} conflict(en)</Badge> : <Badge variant="secondary"><CheckCircle2 size={12} />Geen onopgeloste conflicten</Badge>}{staleCount ? <Badge variant="outline">{staleCount} verouderd</Badge> : null}</div>
|
||||
{knowledge.unresolvedConflictCount ? <div className="rounded-md border border-amber-300 bg-amber-50/60 p-3 text-sm text-amber-950"><p className="font-semibold">Niet alle bronnen zijn het eens</p><p className="mt-1">Repository- en runtimebewijs krijgen voorrang wanneer die verifieerbaar zijn. Alleen echte twijfel wordt menselijke aandacht.</p><ul className="mt-2 space-y-1 text-xs">{knowledge.conflicts.filter((item) => item.status === "attention").slice(0, 4).map((item) => <li key={item.id}>{item.summary}</li>)}</ul></div> : null}
|
||||
|
||||
{primaryTruth.length ? <div className="grid gap-3 lg:grid-cols-2">{primaryTruth.map((group) => <section key={group.label} className="rounded-lg border p-4"><h3 className="font-semibold">{group.label}</h3><div className="mt-3 space-y-3">{group.entries.slice(0, 5).map((item) => <div key={item.id}><div className="flex items-start justify-between gap-2"><p className="text-sm font-medium">{item.title}</p><Badge variant={item.conflicts.length ? "outline" : freshnessVariant(item.freshness)}>{item.conflicts.length ? "Conflict" : freshnessLabel(item.freshness)}</Badge></div><p className="mt-1 text-sm leading-5 text-muted-foreground">{item.summary}</p><p className="mt-1 text-xs text-muted-foreground">{authorityLabel(item.authority)}{item.lastVerified ? ` · geverifieerd ${new Date(item.lastVerified).toLocaleDateString("nl-BE")}` : ""}</p></div>)}</div></section>)}</div> : <p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">Er zijn nog geen kernitems als doel, requirements, constraints, architectuur of beslissingen geclassificeerd. Ondersteunende kennis blijft hieronder beschikbaar.</p>}
|
||||
</> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{knowledge ? <details className="rounded-xl border bg-card"><summary className="cursor-pointer list-none p-4 text-sm font-medium">Bronnen & ondersteunende kennis ({supportingEntries.length})</summary><div className="space-y-3 border-t p-4"><div className="grid gap-2 md:grid-cols-2">{visibleEntries.map((item) => <div key={item.id} className="rounded-md border p-3"><div className="flex flex-wrap items-start justify-between gap-2"><div className="min-w-0"><p className="truncate text-sm font-semibold">{item.title}</p><p className="mt-0.5 text-xs text-muted-foreground">{typeLabel(item.type)} · {sourceLabel(item.source)}</p></div><Badge variant={item.conflicts.length ? "outline" : freshnessVariant(item.freshness)}>{item.conflicts.length ? `${item.conflicts.length} conflict` : freshnessLabel(item.freshness)}</Badge></div><p className="mt-2 line-clamp-3 text-sm text-muted-foreground">{item.summary}</p><div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground"><span className="inline-flex items-center gap-1"><ShieldCheck size={12} />{authorityLabel(item.authority)}</span>{item.confidence !== undefined ? <span>{item.confidence}% zekerheid</span> : null}{item.lastVerified ? <span className="inline-flex items-center gap-1"><Clock3 size={12} />{new Date(item.lastVerified).toLocaleDateString("nl-BE")}</span> : null}</div><details className="mt-2 text-xs"><summary className="cursor-pointer text-muted-foreground">Bron en provenance</summary><pre className="mt-2 max-h-32 overflow-auto rounded bg-muted p-2 whitespace-pre-wrap">{JSON.stringify(item.provenance, null, 2)}</pre></details></div>)}</div>{supportingEntries.length > visibleEntries.length || showAll ? <Button size="sm" variant="outline" onClick={() => setShowAll((value) => !value)}>{showAll ? "Minder tonen" : "Alle kennis tonen"}</Button> : null}</div></details> : null}
|
||||
</div>;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "react-router-dom";
|
||||
import { BookOpen, Clock, ShieldCheck } from "lucide-react";
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, PageHeader } from "@/components/ui";
|
||||
import { getKnowledgeForProject } from "@/shared/lib/projectbrainSelectors";
|
||||
import { projectBrainClientMode } from "@/shared/api/projectBrainClient";
|
||||
import { ProjectKnowledgeCenter } from "@/features/knowledge/components/ProjectKnowledgeCenter";
|
||||
|
||||
const decayVariant = (status?: string) => status === "fresh" ? "default" : status === "watch" ? "secondary" : status === "stale" || status === "obsolete" ? "destructive" : "outline";
|
||||
const decayLabel = (status?: string) => status === "fresh" ? "Actueel" : status === "watch" ? "Controleren" : status === "stale" ? "Verouderd" : status === "obsolete" ? "Niet meer geldig" : "Onbekend";
|
||||
|
||||
const KnowledgeVault = () => {
|
||||
const { projectId } = useParams();
|
||||
if (projectBrainClientMode === "real" && projectId) return <div className="space-y-6"><PageHeader title="Project Knowledge" subtitle="De actuele projectwaarheid eerst; bronnen, geheugen en context blijven ondersteunend bewijs." /><ProjectKnowledgeCenter projectId={projectId} /></div>;
|
||||
|
||||
const knowledge = projectId ? getKnowledgeForProject(projectId) : [];
|
||||
const fresh = knowledge.filter((item) => item.decayStatus === "fresh").length;
|
||||
const watch = knowledge.filter((item) => item.decayStatus === "watch" || item.decayStatus === "stale").length;
|
||||
return <div className="space-y-6">
|
||||
<PageHeader title="Project Knowledge" subtitle="Lokale voorbeeldweergave van projectkennis. Productiewaarheid is pas bewezen in live backendmodus." />
|
||||
<div className="grid gap-3 sm:grid-cols-3"><Card><CardContent className="p-4"><p className="text-2xl font-semibold">{knowledge.length}</p><p className="text-xs text-muted-foreground">kennisitems</p></CardContent></Card><Card><CardContent className="p-4"><p className="text-2xl font-semibold">{fresh}</p><p className="text-xs text-muted-foreground">actueel</p></CardContent></Card><Card><CardContent className="p-4"><p className="text-2xl font-semibold">{watch}</p><p className="text-xs text-muted-foreground">controleren</p></CardContent></Card></div>
|
||||
<Card><CardHeader><CardTitle className="flex items-center gap-2"><BookOpen size={18} />Voorbeeldkennis</CardTitle><CardDescription>Deze fallback toont geen productieclaim en bevat geen losstaande contextpack-workflow.</CardDescription></CardHeader><CardContent className="grid gap-3 md:grid-cols-2">{knowledge.map((item) => <div key={item.id} className="rounded-lg border p-3"><div className="flex items-start justify-between gap-2"><p className="font-semibold">{item.title}</p><Badge variant={decayVariant(item.decayStatus)}>{decayLabel(item.decayStatus)}</Badge></div><p className="mt-1 text-sm text-muted-foreground">{item.summary}</p><div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground"><span className="inline-flex items-center gap-1"><ShieldCheck size={12} />{item.confidence}% zekerheid</span><span className="inline-flex items-center gap-1"><Clock size={12} />{item.lastUpdated}</span></div></div>)}{!knowledge.length ? <p className="text-sm text-muted-foreground">Geen lokale voorbeeldkennis beschikbaar.</p> : null}</CardContent></Card>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default KnowledgeVault;
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Code2,
|
||||
Palette,
|
||||
Workflow,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useProject } from "@/shared/contexts/ProjectContext";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
|
||||
const MemoryRules = () => {
|
||||
const { projectId } = useParams();
|
||||
const { projects, contextPacks } = useProject();
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
const rules = project?.memoryRules ?? [];
|
||||
const [search, setSearch] = useState("");
|
||||
const visibleRules = rules.filter((rule) => `${rule.title} ${rule.description} ${rule.category}`.toLowerCase().includes(search.trim().toLowerCase()));
|
||||
const activePack = project?.activeContextPackId
|
||||
? contextPacks.find((pack) => pack.id === project.activeContextPackId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Memory Rules"
|
||||
subtitle="Define the constraints and rules that guide your project's development."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search rules..." className="pl-10" aria-label="Search memory rules" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{visibleRules.map((rule) => (
|
||||
<Card key={rule.id} className="hover:shadow-sm transition-shadow animate-fade-in">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
{rule.category === "UX" && <Palette size={24} />}
|
||||
{rule.category === "Technical" && <Code2 size={24} />}
|
||||
{rule.category === "Architecture" && <Workflow size={24} />}
|
||||
{rule.category === "Do Not Break" && <ShieldAlert size={24} />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{rule.category}
|
||||
</span>
|
||||
<Badge variant={rule.scope === "global" ? "default" : rule.scope === "project" ? "secondary" : "outline"}>
|
||||
{rule.scope ?? "project"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn(
|
||||
"text-[10px]",
|
||||
rule.priority === "Critical" ? "text-destructive border-destructive/20 bg-destructive/5" : ""
|
||||
)}>
|
||||
{rule.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{rule.impact}</Badge>
|
||||
{rule.scope === "feature" && <Badge variant="outline">feature-scoped</Badge>}
|
||||
{rule.category === "Do Not Break" && (
|
||||
<Badge variant="destructive" className="text-[10px]">Do Not Break</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{rule.title}</h3>
|
||||
{rule.status === "Verified" ? (
|
||||
<CheckCircle2 size={14} className="text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle size={14} className="text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{rule.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Severity: {rule.severity ?? rule.priority} • {rule.linkedFeatureIds?.length ? "linked features" : "no linked features"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Include in active context pack:{" "}
|
||||
{(rule.includeInContextPacks ?? []).includes(activePack?.id ?? "") ? "yes" : "no"}
|
||||
{activePack ? (
|
||||
<Link
|
||||
to={`/projects/${projectId}/context-packs`}
|
||||
className="ml-2 underline underline-offset-4"
|
||||
>
|
||||
open context packs
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{visibleRules.length === 0 && (
|
||||
<div className="text-center py-20 border-2 border-dashed rounded-xl bg-muted/10">
|
||||
<ShieldAlert size={48} className="mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">{rules.length ? "No matching memory rules" : "No memory rules defined"}</h3>
|
||||
<p className="text-muted-foreground">{rules.length ? "Try a broader search." : "Create rules through the database-backed Memory API or Review Queue conversion flow."}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoryRules;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ReviewActionClarity, ReviewDecisionReceiptAction } from "@/shared/lib/reviewDecisionReceipt";
|
||||
|
||||
const reviewActionLabels: Record<ReviewDecisionReceiptAction, string> = {
|
||||
Accept: "Accepteren",
|
||||
Edit: "Bewerken",
|
||||
Reject: "Afwijzen",
|
||||
Merge: "Samenvoegen",
|
||||
Convert: "Omzetten",
|
||||
};
|
||||
|
||||
type ReviewActionPanelProps = {
|
||||
actionClarity: ReviewActionClarity[];
|
||||
activeActionClarity: ReviewActionClarity;
|
||||
actionInFlight: boolean;
|
||||
isRealMode: boolean;
|
||||
selectedAction: ReviewDecisionReceiptAction;
|
||||
selectedReviewIsFinal: boolean;
|
||||
onApply: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onSelectAction: (action: ReviewDecisionReceiptAction) => void;
|
||||
};
|
||||
|
||||
export const ReviewActionPanel = ({
|
||||
actionClarity,
|
||||
activeActionClarity,
|
||||
actionInFlight,
|
||||
isRealMode,
|
||||
selectedAction,
|
||||
selectedReviewIsFinal,
|
||||
onApply,
|
||||
onOpenHistory,
|
||||
onSelectAction,
|
||||
}: ReviewActionPanelProps) => (
|
||||
<div id="review-action-panel" className="scroll-mt-24 space-y-3 rounded-lg border bg-blue-500/5 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Kies je beslissing</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Bekijk de impact van één actie en pas ze daarna bewust toe.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={isRealMode ? "default" : "outline"}>
|
||||
{isRealMode ? "live backend" : "lokale modus"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border bg-background/95 p-2 shadow-sm backdrop-blur">
|
||||
<Button
|
||||
size="sm"
|
||||
data-page-primary="true"
|
||||
disabled={selectedReviewIsFinal || actionInFlight}
|
||||
onClick={onApply}
|
||||
>
|
||||
{actionInFlight ? "Bezig met toepassen…" : reviewActionLabels[selectedAction]}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onOpenHistory}>
|
||||
Geschiedenis bekijken
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:grid lg:grid-cols-2 lg:overflow-visible lg:pb-0">
|
||||
{actionClarity.map((clarity) => (
|
||||
<Button
|
||||
key={clarity.action}
|
||||
size="sm"
|
||||
variant={selectedAction === clarity.action ? "default" : "outline"}
|
||||
className="shrink-0"
|
||||
disabled={selectedReviewIsFinal || actionInFlight}
|
||||
onClick={() => onSelectAction(clarity.action)}
|
||||
>
|
||||
{reviewActionLabels[clarity.action]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{reviewActionLabels[activeActionClarity.action]}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{activeActionClarity.outcome}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
activeActionClarity.riskLevel === "high"
|
||||
? "destructive"
|
||||
: activeActionClarity.riskLevel === "medium"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
risico: {activeActionClarity.riskLevel}
|
||||
</Badge>
|
||||
</div>
|
||||
<details className="mt-3 rounded-md bg-muted/40 p-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer font-medium text-foreground">Impact bekijken</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<p><span className="font-medium text-foreground">Waarheid:</span> {activeActionClarity.truthImpact}</p>
|
||||
<p><span className="font-medium text-foreground">Bewijsstuk:</span> {activeActionClarity.receiptImpact}</p>
|
||||
<p><span className="font-medium text-foreground">Geschiedenis:</span> {activeActionClarity.historyImpact}</p>
|
||||
<p><span className="font-medium text-foreground">Modus:</span> {activeActionClarity.persistence}</p>
|
||||
</div>
|
||||
<p className="mt-2 leading-5">
|
||||
{activeActionClarity.requiresInput ? `${activeActionClarity.requiresInput}: controleer het veld hieronder. ` : ""}
|
||||
{activeActionClarity.warning}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<details className="rounded-md border bg-background p-3 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer text-sm font-medium text-foreground">Alle acties vergelijken</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
{actionClarity.map((clarity) => (
|
||||
<div key={clarity.action} className="rounded-md bg-muted/30 p-2">
|
||||
<p className="font-medium text-foreground">{reviewActionLabels[clarity.action]}</p>
|
||||
<p className="mt-1 leading-5">{clarity.outcome}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
|
||||
const getApprovalStateGuide = (pendingDescription: string) => [
|
||||
["pending", pendingDescription],
|
||||
["accepted", "Reviewer approved. It can become accepted truth."],
|
||||
["edited", "Reviewer changed the suggestion before accepting it."],
|
||||
["merged", "Reviewer merged it into existing project knowledge."],
|
||||
["converted", "Reviewer converted it into a concrete artifact such as feature, task or decision."],
|
||||
["rejected", "Reviewer excluded it from project truth."],
|
||||
] as const;
|
||||
|
||||
interface ReviewApprovalStateGuideProps {
|
||||
title: string;
|
||||
pendingDescription: string;
|
||||
}
|
||||
|
||||
export const ReviewApprovalStateGuide = ({ title, pendingDescription }: ReviewApprovalStateGuideProps) => (
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>
|
||||
Review Queue is the truth gate. Generated, imported or simulated items stay suggestions until a reviewer explicitly accepts, edits, merges or converts them.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 md:grid-cols-3">
|
||||
{getApprovalStateGuide(pendingDescription).map(([state, detail]) => (
|
||||
<div key={state} className="rounded-md border bg-background p-3 text-sm">
|
||||
<Badge variant={state === "rejected" ? "destructive" : state === "pending" ? "outline" : "secondary"}>{state}</Badge>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
import type { RuntimeReviewItem } from "./operationRuntime";
|
||||
import type { ReviewDecisionReceiptAction } from "@/shared/lib/reviewDecisionReceipt";
|
||||
|
||||
export const reviewActions: ReviewDecisionReceiptAction[] = ["Accept", "Edit", "Reject", "Merge", "Convert"];
|
||||
|
||||
export const isSmokeReviewItem = (item: RuntimeReviewItem) => {
|
||||
const haystack = [item.id, item.title, item.description, item.source, item.type].join(" ").toLowerCase();
|
||||
|
||||
return haystack.includes("smoke") || haystack.includes("p5 ") || haystack.includes("p7 ") || haystack.includes("validate p7");
|
||||
};
|
||||
|
||||
export const getReviewImpact = (item: RuntimeReviewItem | undefined) => {
|
||||
if (!item) return [];
|
||||
const base = ["Record the reviewer decision in the project audit trail."];
|
||||
if (item.type.includes("feature")) base.unshift("Create or update feature scope after approval.");
|
||||
if (item.type.includes("documentation")) base.unshift("Update project documentation only after review.");
|
||||
if (item.type.includes("task")) base.unshift("Create an executable task candidate for the next implementation slice.");
|
||||
if (item.type.includes("initiative")) base.unshift("Move roadmap intent toward planned execution.");
|
||||
if ((item as RuntimeReviewItem & { blueprint?: unknown }).blueprint) base.push("Sync the decision back to the linked Blueprint node.");
|
||||
if (item.source === "initiative_center") base.push("Accepted items can materialize initiative-linked Feature Map scope.");
|
||||
return base.slice(0, 5);
|
||||
};
|
||||
Loaded 100 of 700 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user