Files
projectbrain-Public/src/features/context-packs/pages/ContextPacks.tsx
T

579 lines
24 KiB
TypeScript

"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;