Files
2026-09-08 07:21:34 -04:00

1640 lines
72 KiB
TypeScript

"use client";
import React, { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ArtifactType } from "@/lib/types";
import { ARTIFACT_TYPES, RELATIONSHIP_TYPES } from "@/lib/types";
import {
Search,
Layers,
GitBranch,
History,
FileText,
ArrowRightLeft,
ChevronRight,
Network,
Plus,
Loader2,
Pencil,
Trash2,
AlertTriangle,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { artifacts, relationships, projects } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Artifact, ArtifactRelationship } from "@/lib/types";
import ReactFlow, { Background, Controls, MiniMap } from "reactflow";
import "reactflow/dist/style.css";
import type { Node, Edge } from "reactflow";
const DEMO_ARTIFACTS: Artifact[] = [
{
id: "a1", tenant_id: "t1", project_id: "p1",
type: "requirement", external_id: "REQ-441",
title: "Multi-tenant role-based access control",
content: "RBAC: owner, admin, member, viewer with scoped permissions per project.",
status: "in_progress", version: 3, source: "jira",
created_by: "u1", created_at: "2025-10-02T09:12:00Z", updated_at: "2025-11-15T14:22:00Z",
},
{
id: "a2", tenant_id: "t1", project_id: "p1",
type: "task", external_id: "TASK-921",
title: "Implement tenant context middleware",
content: "Add correlation + tenant trust header middleware to FastAPI stack.",
status: "done", version: 1, source: "github",
created_at: "2025-10-05T10:00:00Z", updated_at: "2025-11-12T18:00:00Z",
},
{
id: "a3", tenant_id: "t1", project_id: "p1",
type: "pull_request", external_id: "PR#214",
title: "feat(rbac): tenant middleware + policy evaluator",
status: "done", source: "github",
created_at: "2025-11-10T08:00:00Z", updated_at: "2025-11-12T17:00:00Z",
},
{
id: "a4", tenant_id: "t1", project_id: "p1",
type: "test", external_id: "TEST-88",
title: "tenant_isolation.py — cross-tenant reads",
content: "Pytest suite proving that cross-tenant artifact reads are blocked.",
status: "done", source: "manual",
created_at: "2025-11-13T09:00:00Z", updated_at: "2025-11-13T11:20:00Z",
},
{
id: "a5", tenant_id: "t1", project_id: "p1",
type: "deployment", external_id: "DEP-2025-44",
title: "asf-core v0.4.0 → prod-west-1",
status: "done", source: "argo",
created_at: "2025-11-14T14:00:00Z", updated_at: "2025-11-14T14:12:00Z",
},
];
const DEMO_RELS: ArtifactRelationship[] = [
{ id: "r1", tenant_id: "t1", source_artifact_id: "a2", target_artifact_id: "a1", source_project_id: "p1", target_project_id: "p1", relationship_type: "implements", created_at: "2025-10-05T10:00:00Z", updated_at: "2025-10-05T10:00:00Z" },
{ id: "r2", tenant_id: "t1", source_artifact_id: "a3", target_artifact_id: "a2", source_project_id: "p1", target_project_id: "p1", relationship_type: "generated_from", created_at: "2025-11-10T08:00:00Z", updated_at: "2025-11-10T08:00:00Z" },
{ id: "r3", tenant_id: "t1", source_artifact_id: "a4", target_artifact_id: "a3", source_project_id: "p1", target_project_id: "p1", relationship_type: "verifies", created_at: "2025-11-13T09:00:00Z", updated_at: "2025-11-13T09:00:00Z" },
{ id: "r4", tenant_id: "t1", source_artifact_id: "a5", target_artifact_id: "a3", source_project_id: "p1", target_project_id: "p1", relationship_type: "deployed_as", created_at: "2025-11-14T14:00:00Z", updated_at: "2025-11-14T14:00:00Z" },
];
const REL_COLORS: Record<string, string> = {
implements: "#059669", implemented_by: "#059669",
verifies: "#0284c7", verified_by: "#0284c7",
generated_from: "#7c3aed",
deployed_as: "#d97706",
documented_by: "#db2777",
related_to: "#475569",
caused_by: "#e11d48",
affects: "#ea580c",
depends_on: "#4f46e5",
supersedes: "#65a30d",
};
const TABS = [
{ key: "meta", label: "Metadata", icon: FileText },
{ key: "rels", label: "Relationships", icon: ArrowRightLeft },
{ key: "related", label: "Related", icon: Layers },
{ key: "graph", label: "Graph", icon: Network },
{ key: "history", label: "History", icon: History },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function ContextPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [selectedId, setSelectedId] = useState<string>("");
const [tab, setTab] = useState<TabKey>("meta");
const listQ = useQuery({
queryKey: ["context-artifacts"],
queryFn: () => artifacts.list({ limit: 50 }),
retry: false,
refetchOnWindowFocus: false,
});
const projectsQ = useQuery({
queryKey: ["projects", "list"],
queryFn: () => projects.list({ limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
});
/* ---------- New Generic Artifact modal ---------- */
const [showNewArtifact, setShowNewArtifact] = useState(false);
const [naType, setNaType] = useState<ArtifactType>("task");
const [naTitle, setNaTitle] = useState("");
const [naExternalId, setNaExternalId] = useState("");
const [naStatus, setNaStatus] = useState<string>("backlog");
const [naProjectId, setNaProjectId] = useState<string>("");
const [naContent, setNaContent] = useState("");
const [naSource, setNaSource] = useState("manual");
const [naMetaText, setNaMetaText] = useState<string>("{}\n");
const [naMetaError, setNaMetaError] = useState<string | null>(null);
const parsedNaMeta = useMemo<Record<string, unknown> | null>(() => {
const s = naMetaText.trim();
if (!s) return {};
try {
const v = JSON.parse(s);
if (v === null || typeof v !== "object" || Array.isArray(v)) {
setNaMetaError("metadata must be a JSON object (not null/array/primitive).");
return null;
}
setNaMetaError(null);
return v as Record<string, unknown>;
} catch (e) {
setNaMetaError("Invalid JSON in metadata");
return null;
}
}, [naMetaText]);
const createArtifactM = useMutation({
mutationFn: async () => {
if (!naTitle.trim()) throw new Error("title required");
if (!naProjectId) throw new Error("project required");
if (parsedNaMeta === null) throw new Error("metadata JSON invalid");
return artifacts.create({
project_id: naProjectId,
type: naType,
title: naTitle.trim(),
external_id: naExternalId.trim() || undefined,
status: naStatus || undefined,
content: naContent.trim() || undefined,
source: naSource.trim() || undefined,
metadata: parsedNaMeta,
});
},
onSuccess: (data) => {
setShowNewArtifact(false);
setNaType("task"); setNaTitle(""); setNaExternalId(""); setNaStatus("backlog");
setNaProjectId(""); setNaContent(""); setNaSource("manual"); setNaMetaText("{}\n");
queryClient.invalidateQueries({ queryKey: ["context-artifacts"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
setSelectedId(String(data.id));
},
});
/* ---------- Add Relationship modal ---------- */
const [showNewRel, setShowNewRel] = useState(false);
const [nrSourceId, setNrSourceId] = useState<string>("");
const [nrRelType, setNrRelType] = useState<typeof RELATIONSHIP_TYPES[number]>("implements");
const [nrTargetId, setNrTargetId] = useState<string>("");
const [nrTargetSearch, setNrTargetSearch] = useState("");
const addRelM = useMutation({
mutationFn: async () => {
if (!nrSourceId) throw new Error("source artifact required");
if (!nrTargetId) throw new Error("target artifact required");
if (nrSourceId === nrTargetId) throw new Error("source and target must be different");
return relationships.create({
source_artifact_id: nrSourceId,
target_artifact_id: nrTargetId,
relationship_type: nrRelType,
});
},
onSuccess: () => {
setShowNewRel(false);
setNrSourceId(""); setNrTargetId(""); setNrRelType("implements"); setNrTargetSearch("");
queryClient.invalidateQueries({ queryKey: ["context-rels"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["context-related-2hop"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["rels", "for"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["context-artifacts"] }).catch(() => undefined);
},
});
const targetCandidates = useMemo(() => {
const s = nrTargetSearch.trim().toLowerCase();
const list = (listQ.data ?? []).filter((a) => String(a.id) !== String(nrSourceId));
if (!s) return list.slice(0, 12);
return list.filter((a) =>
`${a.title} ${a.external_id ?? ""} ${a.type}`.toLowerCase().includes(s),
).slice(0, 12);
}, [listQ.data, nrTargetSearch, nrSourceId]);
/* ---------- Delete Artifact confirm modal ---------- */
const [showDelArtifact, setShowDelArtifact] = useState(false);
const deleteArtifactM = useMutation({
mutationFn: async () => {
if (!selected?.id) throw new Error("no artifact selected");
return artifacts.remove(String(selected.id));
},
onSuccess: () => {
setShowDelArtifact(false);
setSelectedId("");
queryClient.invalidateQueries({ queryKey: ["context-artifacts"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["traceability-graph"] }).catch(() => undefined);
},
});
/* ---------- Delete Relationship per row ---------- */
const [delRelId, setDelRelId] = useState<string | null>(null);
const deleteRelM = useMutation({
mutationFn: async (id: string) => relationships.remove(id),
onSuccess: () => {
setDelRelId(null);
queryClient.invalidateQueries({ queryKey: ["context-rels"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["context-related-2hop"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["rels", "for"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["context-artifacts"] }).catch(() => undefined);
},
});
/* ---------- Edit Artifact PATCH modal ---------- */
const [showEditArtifact, setShowEditArtifact] = useState(false);
const [eaType, setEaType] = useState<ArtifactType>("task");
const [eaTitle, setEaTitle] = useState("");
const [eaExternalId, setEaExternalId] = useState("");
const [eaStatus, setEaStatus] = useState<string>("");
const [eaProjectId, setEaProjectId] = useState<string>("");
const [eaContent, setEaContent] = useState("");
const [eaSource, setEaSource] = useState("");
const [eaMetaText, setEaMetaText] = useState<string>("{}\n");
const [eaMetaError, setEaMetaError] = useState<string | null>(null);
const parsedEaMeta = useMemo<Record<string, unknown> | null>(() => {
const s = eaMetaText.trim();
if (!s) return {};
try {
const v = JSON.parse(s);
if (v === null || typeof v !== "object" || Array.isArray(v)) {
setEaMetaError("metadata must be a JSON object (not null/array/primitive).");
return null;
}
setEaMetaError(null);
return v as Record<string, unknown>;
} catch (e) {
setEaMetaError("Invalid JSON in metadata");
return null;
}
}, [eaMetaText]);
const openEdit = () => {
if (!selected) return;
setEaType(selected.type as ArtifactType);
setEaTitle(selected.title ?? "");
setEaExternalId(selected.external_id ?? "");
setEaStatus(selected.status ?? "");
setEaProjectId(String(selected.project_id));
setEaContent(selected.content ?? "");
setEaSource(selected.source ?? "");
try {
setEaMetaText(selected.metadata && Object.keys(selected.metadata).length > 0
? JSON.stringify(selected.metadata, null, 2) + "\n"
: "{}\n");
} catch {
setEaMetaText("{}\n");
}
setEaMetaError(null);
setShowEditArtifact(true);
};
const editArtifactM = useMutation({
mutationFn: async () => {
if (!selected?.id) throw new Error("no artifact");
if (!eaTitle.trim()) throw new Error("title required");
if (!eaProjectId) throw new Error("project required");
if (parsedEaMeta === null) throw new Error("metadata JSON invalid");
return artifacts.update(String(selected.id), {
project_id: eaProjectId,
type: eaType,
title: eaTitle.trim(),
external_id: eaExternalId.trim() || undefined,
status: eaStatus || undefined,
content: eaContent.trim() || undefined,
source: eaSource.trim() || undefined,
metadata: parsedEaMeta,
});
},
onSuccess: () => {
setShowEditArtifact(false);
queryClient.invalidateQueries({ queryKey: ["context-artifacts"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["context-rels"] }).catch(() => undefined);
},
});
// If picker has data, auto-select first row on first load (only if user hasn't set one)
const hasRowsApi = listQ.isFetched && listQ.data !== undefined && listQ.data.length > 0;
const effectiveSelectedId =
selectedId && (listQ.data ?? []).some((x) => String(x.id) === String(selectedId))
? selectedId
: hasRowsApi
? String(listQ.data![0].id)
: selectedId || "a1";
const relsQ = useQuery({
queryKey: ["context-rels", effectiveSelectedId],
queryFn: () => relationships.forArtifact(effectiveSelectedId, { limit: 100 }),
enabled: !!effectiveSelectedId,
retry: false,
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Artifact[] = apiResolved ? listQ.data! : DEMO_ARTIFACTS;
const isMock = !apiResolved;
const relsResolved = relsQ.isFetched && relsQ.data !== undefined;
const rels: ArtifactRelationship[] = relsResolved
? relsQ.data!
: DEMO_RELS.filter(
(r) =>
String(r.source_artifact_id) === String(effectiveSelectedId) ||
String(r.target_artifact_id) === String(effectiveSelectedId),
);
// Build a "universal lookup" for artifacts: picker rows + any related artifact ID not yet in rows
// We create a small lazy query map to fetch related if missing, but fallback using rows + rels
const artifactsById = useMemo(() => {
const m = new Map<string, Artifact>();
for (const a of rows) m.set(String(a.id), a);
return m;
}, [rows]);
const filtered = useMemo(() => {
const ql = q.trim().toLowerCase();
if (!ql) return rows;
return rows.filter((r) =>
`${r.title} ${r.external_id ?? ""} ${r.type}`.toLowerCase().includes(ql),
);
}, [rows, q]);
const selected = rows.find((r) => String(r.id) === String(effectiveSelectedId)) ?? rows[0] ?? null;
type RelatedItem = {
artifact: Artifact;
hop: 1 | 2;
viaRelationship: string;
linkedToId?: string;
linkedToTitle?: string;
};
const relatedQ = useQuery({
queryKey: ["context-related-2hop", effectiveSelectedId, apiResolved, relsResolved, rows.map((r) => r.id).join(",")],
enabled: !!effectiveSelectedId && !!selected,
staleTime: 60_000,
queryFn: async (): Promise<RelatedItem[]> => {
if (!selected) return [];
const selId = String(selected.id);
// 1-hop neighbors
const hop1 = new Map<string, { rel: ArtifactRelationship; viaRel: string }>();
for (const r of rels) {
const isOut = String(r.source_artifact_id) === selId;
const otherId = isOut ? String(r.target_artifact_id) : String(r.source_artifact_id);
if (otherId === selId) continue;
if (!hop1.has(otherId)) hop1.set(otherId, { rel: r, viaRel: r.relationship_type });
}
// Build a temp wide map: includes picker rows + DEMO fallback + any artifact we may discover later
const wideArtifacts = new Map<string, Artifact>();
for (const a of rows) wideArtifacts.set(String(a.id), a);
for (const a of DEMO_ARTIFACTS) if (!wideArtifacts.has(String(a.id))) wideArtifacts.set(String(a.id), a);
const items: RelatedItem[] = [];
const seen = new Set<string>([selId]);
// Pass 1: add hop 1
for (const [otherId, { viaRel }] of hop1) {
if (seen.has(otherId)) continue;
const artifact = wideArtifacts.get(otherId);
if (!artifact) continue;
seen.add(otherId);
items.push({ artifact, hop: 1, viaRelationship: viaRel });
}
// Pass 2: hop 2 neighbors (neighbors of 1-hop). Only fetch if API resolved
if (apiResolved && relsResolved) {
const hop1Ids = Array.from(hop1.keys());
try {
const neighborRels = await Promise.all(
hop1Ids.map(async (id) => {
try {
return await relationships.forArtifact(id, { limit: 50 });
} catch {
return [];
}
}),
);
// Merge into an adjacency: for each original hop1, what are its other neighbors besides selId?
hop1Ids.forEach((hop1Id, idx) => {
const rs = neighborRels[idx] ?? [];
const hop1Artifact = wideArtifacts.get(hop1Id);
for (const r of rs) {
const isOut = String(r.source_artifact_id) === hop1Id;
const candidate = isOut ? String(r.target_artifact_id) : String(r.source_artifact_id);
if (seen.has(candidate)) continue;
const artifact = wideArtifacts.get(candidate) || DEMO_ARTIFACTS.find((d) => String(d.id) === candidate);
if (!artifact) continue;
seen.add(candidate);
items.push({
artifact,
hop: 2,
viaRelationship: r.relationship_type,
linkedToId: hop1Id,
linkedToTitle: hop1Artifact?.title ?? hop1Id,
});
}
});
} catch {
// ignore network errors; we still have hop 1
}
} else if (relsResolved === false) {
// DEMO mode: derive 2-hop from DEMO_RELS graph
const demoGraph = new Map<string, string[]>();
const addEdge = (a: string, b: string) => {
if (!demoGraph.has(a)) demoGraph.set(a, []);
demoGraph.get(a)!.push(b);
};
for (const dr of DEMO_RELS) {
addEdge(String(dr.source_artifact_id), String(dr.target_artifact_id));
addEdge(String(dr.target_artifact_id), String(dr.source_artifact_id));
}
const hop1Ids = Array.from(hop1.keys());
for (const hop1Id of hop1Ids) {
const others = demoGraph.get(hop1Id) ?? [];
const hop1Artifact = wideArtifacts.get(hop1Id) ?? DEMO_ARTIFACTS.find((d) => String(d.id) === hop1Id);
for (const candidate of others) {
if (seen.has(candidate)) continue;
const artifact = wideArtifacts.get(candidate) ?? DEMO_ARTIFACTS.find((d) => String(d.id) === candidate);
if (!artifact) continue;
const dr = DEMO_RELS.find(
(x) =>
(String(x.source_artifact_id) === hop1Id && String(x.target_artifact_id) === candidate) ||
(String(x.target_artifact_id) === hop1Id && String(x.source_artifact_id) === candidate),
);
seen.add(candidate);
items.push({
artifact,
hop: 2,
viaRelationship: dr?.relationship_type ?? "related",
linkedToId: hop1Id,
linkedToTitle: hop1Artifact?.title ?? hop1Id,
});
}
}
}
// sort: hop 1 first, then updated_at newest first
items.sort((a, b) => {
if (a.hop !== b.hop) return a.hop - b.hop;
return (b.artifact.updated_at ?? "").localeCompare(a.artifact.updated_at ?? "");
});
return items;
},
});
const flowGraph = useMemo<{ nodes: Node[]; edges: Edge[] } | null>(() => {
if (!selected) return null;
const selId = String(selected.id);
// Wide artifact map (rows + DEMO)
const wideMap = new Map<string, Artifact>();
for (const a of rows) wideMap.set(String(a.id), a);
for (const a of DEMO_ARTIFACTS) if (!wideMap.has(String(a.id))) wideMap.set(String(a.id), a);
// Hop assignments
const hopById = new Map<string, 0 | 1 | 2>();
hopById.set(selId, 0);
// Hop-1 neighbors + DIRECT edges (from rels)
const hop1Ids: string[] = [];
const edgesToRender: Array<{ rel: ArtifactRelationship; tier: 1 | 2 }> = [];
for (const r of rels) {
const isOut = String(r.source_artifact_id) === selId;
const other = isOut ? String(r.target_artifact_id) : String(r.source_artifact_id);
if (other === selId) continue;
edgesToRender.push({ rel: r, tier: 1 });
if (!hopById.has(other)) {
hopById.set(other, 1);
hop1Ids.push(other);
}
}
// Hop-2 + HOP1<->HOP2 edges derived from relatedQ (if loaded) — fallback empty list if still loading
const relatedItems: RelatedItem[] = relatedQ.isFetched && relatedQ.data ? relatedQ.data : [];
// Build hop2 set + hop1<->hop2 edge map from relatedQ.linkedToId + viaRelationship
const hop2Ids: string[] = [];
for (const it of relatedItems) {
const id = String(it.artifact.id);
if (id === selId) continue;
if (it.hop === 2 && !hopById.has(id)) {
hopById.set(id, 2);
hop2Ids.push(id);
} else if (it.hop === 1 && !hopById.has(id)) {
hopById.set(id, 1);
if (!hop1Ids.includes(id)) hop1Ids.push(id);
}
// Fabricate hop1<->hop2 synthetic edge (non-ArtifactRelationship object) — track via tuple
}
// Also load edges for hop1->hop2 from artifact-level relationship lookups already in wideMap
// Use relatedQ to detect hop1<>hop2 links: if item has linkedToId, src=linkedToId (already in hop1 or 0), dst=artifact.id
type SyEdge = { id: string; source: string; target: string; relType: string };
const hop2Edges: SyEdge[] = [];
for (const it of relatedItems) {
if (it.hop === 2 && it.linkedToId) {
const src = it.linkedToId;
const dst = String(it.artifact.id);
hop2Edges.push({
id: `sye-${src}-${dst}-${it.viaRelationship}`,
source: src,
target: dst,
relType: it.viaRelationship,
});
}
}
// Node layout: center (0,0), inner ring hop1, outer ring hop2
const R1 = hop1Ids.length === 1 ? 200 : Math.max(220, 180 + hop1Ids.length * 12);
const R2 = R1 + (hop2Ids.length === 1 ? 180 : Math.max(220, 200 + Math.min(hop2Ids.length, 12) * 18));
const nodeById = new Map<string, Node>();
function makeNode(id: string, ring: 0 | 1 | 2, ringIdx: number, ringSize: number): Node {
const art = wideMap.get(id);
const label = art ? truncate(art.title, 32) : shortId(id);
const type = (art?.type ?? "unknown") as ArtifactType | "unknown";
const extId = art?.external_id ?? shortId(id);
let x = 0, y = 0;
if (ring === 0) { x = 0; y = 0; }
else {
const R = ring === 1 ? R1 : R2;
const angle = ringSize === 1 ? -Math.PI / 2 : (ringIdx / ringSize) * 2 * Math.PI - Math.PI / 2;
x = Math.cos(angle) * R;
y = Math.sin(angle) * R;
}
const isSel = ring === 0;
const BORDER = isSel ? "#4f46e5" : ring === 1 ? "#a78bfa" : "#cbd5e1";
const BG = isSel ? "#eef2ff" : ring === 1 ? "#faf5ff" : "#f8fafc";
const TEXT = isSel ? "#3730a3" : ring === 1 ? "#6d28d9" : "#0f172a";
return {
id,
position: { x, y },
data: {
label: (
<div style={{ textAlign: "left" as const, minWidth: 150, maxWidth: 240, padding: 2 }}>
<div style={{ display: "flex", gap: 4, alignItems: "center", marginBottom: 3, flexWrap: "wrap" }}>
<span
className="asf-monospace"
style={{ fontSize: 10, color: isSel ? "#4338ca" : "#64748b", fontWeight: 600 }}
>
{extId}
</span>
{art ? (
<span
style={{
fontSize: 9, padding: "1px 6px", borderRadius: 999,
background: ring === 1 ? "#ede9fe" : "#f1f5f9",
border: `1px solid ${ring === 1 ? "#ddd6fe" : "#e2e8f0"}`,
color: ring === 1 ? "#6d28d9" : "#334155", fontWeight: 600,
}}
>
{type}
</span>
) : null}
{isSel ? (
<span
style={{
fontSize: 9, padding: "1px 7px", borderRadius: 999, fontWeight: 700,
background: "#4f46e5", color: "white", marginLeft: "auto",
}}
>
SELECTED
</span>
) : ring === 1 ? (
<span
style={{
fontSize: 9, padding: "1px 7px", borderRadius: 999, fontWeight: 700,
background: "#d97706", color: "white", marginLeft: "auto",
}}
>
HOP 1
</span>
) : (
<span
style={{
fontSize: 9, padding: "1px 7px", borderRadius: 999, fontWeight: 700,
background: "#94a3b8", color: "white", marginLeft: "auto",
}}
>
HOP 2
</span>
)}
</div>
<div
style={{
fontSize: 12, lineHeight: 1.3, color: TEXT,
fontWeight: isSel ? 600 : 500,
}}
>
{label}
</div>
</div>
),
},
style: {
background: BG,
border: `2px solid ${BORDER}`,
borderRadius: 12,
padding: isSel ? "9px 12px" : "7px 11px",
boxShadow: isSel
? "0 6px 26px rgba(79,70,229,0.20), 0 1px 5px rgba(15,23,42,0.08)"
: ring === 1
? "0 4px 18px rgba(167,139,250,0.16), 0 1px 3px rgba(15,23,42,0.06)"
: "0 2px 10px rgba(15,23,42,0.05)",
width: "auto",
minWidth: 190,
},
};
}
nodeById.set(selId, makeNode(selId, 0, 0, 1));
hop1Ids.forEach((id, i) => nodeById.set(id, makeNode(id, 1, i, hop1Ids.length)));
hop2Ids.forEach((id, i) => nodeById.set(id, makeNode(id, 2, i, hop2Ids.length)));
// Edges: tier 1 (solid, full color) + hop2 synthetic (dashed, lighter)
const edges: Edge[] = [];
const edgeColors = REL_COLORS;
edgesToRender.forEach(({ rel }, i) => {
const color = edgeColors[rel.relationship_type.toLowerCase()] ?? "#64748b";
edges.push({
id: `e1-${rel.id}-${i}`,
source: String(rel.source_artifact_id),
target: String(rel.target_artifact_id),
label: rel.relationship_type,
labelStyle: {
fontSize: 10, fontWeight: 700, fill: color,
background: "white", padding: 2, borderRadius: 4,
border: `1px solid ${color}55`,
},
style: { stroke: color, strokeWidth: 2.1 },
animated: false,
});
});
hop2Edges.forEach((e) => {
const color = edgeColors[e.relType.toLowerCase()] ?? "#94a3b8";
edges.push({
id: `e2-${e.id}`,
source: e.source,
target: e.target,
label: e.relType,
labelStyle: {
fontSize: 9, fontWeight: 600, fill: color, opacity: 0.9,
background: "white", padding: 2, borderRadius: 4,
border: `1px dashed ${color}66`,
},
style: {
stroke: color, strokeWidth: 1.3, strokeDasharray: "6 4", opacity: 0.78,
},
animated: false,
});
});
return { nodes: Array.from(nodeById.values()), edges };
}, [selected, rels, rows, relatedQ.data, relatedQ.isFetched]);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Search className="w-6 h-6 text-brand-600" /> Context Explorer
</h1>
<p className="mt-1 text-sm text-slate-500">
Select an Artifact and navigate its metadata, relationships, related artifacts and change history.
</p>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-4 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Artifact picker</CardTitle>
{isMock && <Badge tone="amber">Demo data</Badge>}
</CardHeader>
<CardBody className="p-0 flex flex-col min-h-0">
<div className="p-3 border-b border-slate-100 space-y-2">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search artifacts…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
className="flex-1"
onClick={() => {
setShowNewArtifact(true);
if ((projectsQ.data ?? []).length > 0 && !naProjectId) {
setNaProjectId(String(projectsQ.data![0].id));
}
if (selected?.project_id && !naProjectId) {
setNaProjectId(String(selected.project_id));
}
}}
>
<Plus className="w-4 h-4" /> New Artifact
</Button>
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => {
setShowNewRel(true);
if (selected?.id && !nrSourceId) {
setNrSourceId(String(selected.id));
}
setNrTargetId("");
setNrTargetSearch("");
}}
>
<ArrowRightLeft className="w-4 h-4" /> Link
</Button>
</div>
</div>
<ul className="overflow-auto flex-1 min-h-0 divide-y divide-slate-100">
{filtered.length === 0 && (
<li className="p-6">
<EmptyState title="No matches" description="Try another keyword." />
</li>
)}
{filtered.map((a) => {
const active = a.id === selected?.id;
return (
<li key={a.id}>
<button
type="button"
onClick={() => {
setSelectedId(String(a.id));
setTab("meta");
}}
className={cn(
"w-full text-left px-3 py-3 flex items-start gap-3 hover:bg-slate-50 transition-colors",
active && "bg-brand-50/70",
)}
>
<div className="w-8 h-8 mt-0.5 rounded-lg bg-slate-50 border border-slate-200 grid place-items-center shrink-0">
<Layers className="w-4 h-4 text-slate-500" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="asf-monospace text-xs text-slate-500">
{a.external_id ?? shortId(a.id)}
</span>
<ArtifactTypeBadge type={a.type} />
</div>
<div className="mt-1 text-sm font-medium text-slate-800 truncate">
{a.title}
</div>
<div className="mt-0.5 text-xs text-slate-500 truncate">
{truncate(a.content ?? "", 80) || "No description"}
</div>
</div>
<ChevronRight className={cn("w-4 h-4 mt-1 shrink-0 transition-opacity", active ? "text-brand-600" : "text-slate-300")} />
</button>
</li>
);
})}
</ul>
</CardBody>
</Card>
<Card className="lg:col-span-8 flex flex-col min-h-0">
<CardHeader className="flex-wrap gap-3">
<div className="min-w-0 flex-1">
{selected ? (
<>
<div className="flex items-center gap-2 mb-1">
<span className="asf-monospace text-xs text-slate-500">
{selected.external_id ?? shortId(selected.id)}
</span>
<ArtifactTypeBadge type={selected.type} />
{selected.status && <Badge tone="brand">{selected.status}</Badge>}
</div>
<CardTitle className="!text-base truncate">{selected.title}</CardTitle>
</>
) : (
<CardTitle>Select an artifact</CardTitle>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{selected && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={openEdit} title="Edit artifact">
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="!text-red-600 hover:!bg-red-50"
onClick={() => setShowDelArtifact(true)}
title="Delete artifact"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex gap-1 p-0.5 rounded-lg bg-slate-100 text-xs font-medium">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md transition-colors",
tab === key ? "bg-white text-slate-800 shadow-sm" : "text-slate-500 hover:text-slate-700",
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{!selected ? (
<EmptyState
title="No artifact selected"
description="Pick an artifact from the list on the left to inspect its context."
icon={Layers}
/>
) : tab === "meta" ? (
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="md:col-span-2 p-3 rounded-lg bg-slate-50 border border-slate-100 text-slate-700 whitespace-pre-wrap">
{selected.content || <span className="text-slate-400 italic">No content body provided.</span>}
</div>
<div><dt className="text-xs text-slate-500">Project ID</dt><dd className="asf-monospace">{selected.project_id}</dd></div>
<div><dt className="text-xs text-slate-500">Tenant ID</dt><dd className="asf-monospace">{shortId(selected.tenant_id)}</dd></div>
<div><dt className="text-xs text-slate-500">Source</dt><dd>{selected.source ?? "—"}</dd></div>
<div><dt className="text-xs text-slate-500">Version</dt><dd>{selected.version ?? "—"}</dd></div>
<div><dt className="text-xs text-slate-500">Created</dt><dd>{fmtDate(selected.created_at)}</dd></div>
<div><dt className="text-xs text-slate-500">Updated</dt><dd>{fmtDate(selected.updated_at)}</dd></div>
</dl>
) : tab === "rels" ? (
rels.length === 0 ? (
<EmptyState title="No relationships" description="This artifact has no outgoing or incoming links yet." icon={GitBranch} />
) : (
<ul className="divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
{rels.map((r) => {
const isOut = r.source_artifact_id === selected.id;
const otherId = isOut ? r.target_artifact_id : r.source_artifact_id;
const other = rows.find((x) => x.id === otherId);
const isDeleting = delRelId === String(r.id);
return (
<li key={r.id} className={cn("flex items-center gap-3 px-3 py-3 text-sm hover:bg-slate-50", isDeleting && "opacity-60 pointer-events-none")}>
<Badge tone="brand">{isOut ? "out" : "in"}</Badge>
<div className="asf-monospace text-xs text-slate-500">
{isOut ? "→" : "←"} {r.relationship_type}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate">
{other?.title ?? otherId}
</div>
<div className="text-xs text-slate-500 flex items-center gap-2">
{other && <ArtifactTypeBadge type={other.type} />}
<span className="asf-monospace">{shortId(otherId)}</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedId(otherId)}>
Open
</Button>
<Button
variant="ghost"
size="sm"
className="!text-red-600 hover:!bg-red-50"
onClick={() => {
setDelRelId(String(r.id));
void deleteRelM.mutateAsync(String(r.id));
}}
title="Delete relationship"
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</Button>
</li>
);
})}
</ul>
)
) : tab === "related" ? (
(() => {
const items = relatedQ.data ?? [];
const loading = relatedQ.isFetching && !relatedQ.data;
if (loading) {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="animate-pulse p-3 rounded-lg border border-slate-100 bg-slate-50 h-16" />
))}
</div>
);
}
if (items.length === 0) {
return (
<EmptyState
title="No related artifacts"
description="This artifact has no 1-hop or 2-hop neighbors in the relationship graph."
icon={Layers}
/>
);
}
return (
<div className="space-y-2 text-sm">
{items.map((item) => (
<button
key={item.artifact.id}
type="button"
onClick={() => setSelectedId(String(item.artifact.id))}
className="w-full text-left flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-brand-200 hover:bg-brand-50/30 transition-colors"
>
<div className="min-w-0 flex items-center gap-3">
<div className="flex items-center gap-2 shrink-0">
<ArtifactTypeBadge type={item.artifact.type as ArtifactType} />
<Badge tone={item.hop === 1 ? "brand" : "neutral"} className="uppercase text-[10px] tracking-wide">
hop {item.hop}
</Badge>
</div>
<div className="min-w-0">
<div className="font-medium text-slate-800 truncate">{item.artifact.title}</div>
<div className="text-xs text-slate-500 flex items-center gap-2 flex-wrap">
<span>{fmtDate(item.artifact.updated_at)}</span>
<span className="asf-monospace text-[11px] px-1.5 py-0.5 bg-slate-100 rounded">
{item.viaRelationship}
</span>
{item.hop === 2 && item.linkedToTitle && (
<span className="text-slate-400">
via <span className="text-slate-600">{truncate(item.linkedToTitle, 32)}</span>
</span>
)}
</div>
</div>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 shrink-0" />
</button>
))}
</div>
);
})()
) : tab === "graph" ? (
(() => {
if (!flowGraph || flowGraph.nodes.length === 0) {
return (
<EmptyState
title="No graph to render"
description="This artifact has no 1-hop relationships yet. Create some edges to see the network."
icon={GitBranch}
/>
);
}
const neighCount = flowGraph.nodes.length - 1;
const tier1 = flowGraph.nodes.filter((n) => (n.style?.border as string)?.includes("#a78bfa")).length;
const tier2 = neighCount - tier1;
return (
<div className="flex flex-col gap-3 h-full min-h-[480px]">
<div className="flex items-center justify-between gap-3 flex-wrap text-xs">
<div className="flex items-center gap-2 flex-wrap">
<Badge tone="brand">nodes {flowGraph.nodes.length}</Badge>
{tier1 > 0 && <Badge tone="amber">hop 1 · {tier1}</Badge>}
{tier2 > 0 && <Badge tone="neutral">hop 2 · {tier2}</Badge>}
<Badge tone="neutral">edges {flowGraph.edges.length}</Badge>
</div>
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(REL_COLORS).slice(0, 11).map(([k, c]) => (
<span key={k} className="inline-flex items-center gap-1">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: c }}
/>
<span className="text-[10px] text-slate-500 asf-monospace">{k}</span>
</span>
))}
</div>
</div>
<div className="flex-1 border border-slate-100 rounded-xl bg-gradient-to-br from-slate-50/60 via-white to-white overflow-hidden">
<ReactFlow
nodes={flowGraph.nodes}
edges={flowGraph.edges}
defaultViewport={{ x: 20, y: 20, zoom: 1.05 }}
minZoom={0.3}
maxZoom={2.2}
onNodeClick={(_e, n) => setSelectedId(n.id)}
nodesDraggable
panOnDrag
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.4 }}
>
<Background gap={14} color="#e2e8f0" />
<Controls
showInteractive={false}
className="!rounded-lg !overflow-hidden !border !border-slate-200 !shadow-sm"
/>
<MiniMap
pannable
zoomable
nodeColor={(n) => {
const b = n.style?.border as string | undefined;
if (!b) return "#cbd5e1";
if (b.includes("#4f46e5")) return "#4f46e5";
if (b.includes("#a78bfa")) return "#a78bfa";
return "#94a3b8";
}}
nodeStrokeWidth={3}
maskColor="rgba(148,163,184,0.08)"
className="!rounded-lg !overflow-hidden !border !border-slate-200"
/>
</ReactFlow>
</div>
<p className="text-[11px] text-slate-400">
Click any node in the graph to inspect it · drag to pan · scroll to zoom
</p>
</div>
);
})()
) : (
(() => {
type TimelineEvent = {
date: string;
label: string;
desc: string;
tone?: "brand" | "success" | "neutral" | "amber";
dotIcon?: string;
};
const selId = String(selected.id);
const events: TimelineEvent[] = [];
events.push({
date: selected.created_at,
label: "Created",
desc: `Artifact created via ${selected.source ?? "manual"}.`,
tone: "success",
dotIcon: "✓",
});
const upd = selected.updated_at;
const crt = selected.created_at;
if (upd && crt && new Date(upd).getTime() - new Date(crt).getTime() > 1000) {
events.push({
date: upd,
label: "Updated",
desc: "Metadata or content modified.",
tone: "brand",
});
}
if (typeof selected.version === "number" && selected.version > 1) {
events.push({
date: upd ?? crt,
label: "Version bump",
desc: `Current version: v${selected.version}.`,
tone: "amber",
});
}
for (const r of rels) {
const isOut = String(r.source_artifact_id) === selId;
const otherId = isOut ? r.target_artifact_id : r.source_artifact_id;
const other = rows.find((x) => String(x.id) === String(otherId));
const dir = isOut ? "→" : "←";
events.push({
date: r.created_at ?? selected.created_at,
label: `Relationship added`,
desc: `${dir} ${r.relationship_type} ${other ? `with "${truncate(other.title, 50)}"` : `with artifact ${shortId(String(otherId))}`}.`,
tone: "neutral",
});
}
events.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (events.length === 0) {
return <EmptyState title="No history" description="This artifact has no recorded events yet." icon={History} />;
}
const toneColor: Record<NonNullable<TimelineEvent["tone"]>, string> = {
brand: "border-brand-400 bg-brand-500",
success: "border-emerald-400 bg-emerald-500",
amber: "border-amber-400 bg-amber-500",
neutral: "border-slate-400 bg-slate-400",
};
return (
<ol className="relative border-s border-slate-200 ms-2 space-y-5 text-sm">
{events.map((e, i) => {
const tone = e.tone ?? "brand";
return (
<li key={i} className="ms-6">
<span
className={`absolute -start-2.5 mt-1.5 w-5 h-5 rounded-full bg-white border-2 grid place-items-center text-[10px] text-white font-semibold ${toneColor[tone].split(" ")[0]}`}
>
<span className={`w-2 h-2 rounded-full ${toneColor[tone].split(" ")[1]}`} />
</span>
<div className="flex items-baseline gap-2 flex-wrap">
<h4 className="font-semibold text-slate-800">{e.label}</h4>
<span className="text-xs text-slate-500">{fmtDate(e.date)}</span>
</div>
<p className="text-slate-600 mt-0.5 leading-snug">{e.desc}</p>
</li>
);
})}
</ol>
);
})()
)}
</CardBody>
</Card>
</div>
{/* ---------- New Artifact Modal ---------- */}
{showNewArtifact && (
<div
className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center p-4 pt-16 overflow-y-auto"
onClick={() => !createArtifactM.isPending && setShowNewArtifact(false)}
>
<div
className="bg-white w-full max-w-2xl rounded-2xl shadow-xl border border-slate-200 max-h-[90vh] overflow-hidden flex flex-col my-8"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 px-6 py-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Plus className="w-5 h-5 text-brand-600" /> New Artifact
</h3>
<p className="mt-1 text-sm text-slate-500">
Create any artifact type (17 available). Title and project are required.
</p>
</div>
</div>
<form
className="p-6 space-y-4 overflow-y-auto"
onSubmit={(e) => {
e.preventDefault();
void createArtifactM.mutateAsync();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Type *</label>
<select
className="asf-input"
value={naType}
onChange={(e) => setNaType(e.target.value as ArtifactType)}
>
{ARTIFACT_TYPES.map((t) => (
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Status</label>
<input
className="asf-input"
placeholder="backlog, todo, done, fixed…"
value={naStatus}
onChange={(e) => setNaStatus(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">Title *</label>
<input
className="asf-input"
placeholder="Short descriptive title"
value={naTitle}
onChange={(e) => setNaTitle(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">External ID</label>
<input
className="asf-input asf-monospace"
placeholder="REQ-401, PR#99, TICKET-12…"
value={naExternalId}
onChange={(e) => setNaExternalId(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Project *</label>
<select
className="asf-input"
value={naProjectId}
onChange={(e) => setNaProjectId(e.target.value)}
>
<option value="">— select project —</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{p.key ? `[${p.key}] ` : ""}{p.name ?? shortId(String(p.id))}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Source</label>
<input
className="asf-input"
placeholder="manual, jira, github, ci…"
value={naSource}
onChange={(e) => setNaSource(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">Content / Description</label>
<textarea
className="asf-input min-h-[96px] resize-y"
rows={4}
placeholder="Full body, description, acceptance criteria…"
value={naContent}
onChange={(e) => setNaContent(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Metadata (JSON)
</label>
<textarea
className={cn(
"asf-input asf-monospace text-xs min-h-[100px] resize-y",
naMetaError && "border-red-300 focus:ring-red-200",
)}
rows={4}
value={naMetaText}
onChange={(e) => setNaMetaText(e.target.value)}
placeholder='{ "kind": "code_smell", "severity": "high" }'
/>
{naMetaError && (
<p className="mt-1 text-xs text-red-600">{naMetaError}</p>
)}
<p className="mt-1 text-[11px] text-slate-400">
Tip: for code smells use <code className="bg-slate-100 px-1 rounded">type=ai_analysis</code> and <code className="bg-slate-100 px-1 rounded">`{ '"kind": "code_smell"' }`</code> in metadata.
</p>
</div>
</div>
{createArtifactM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{(createArtifactM.error as Error)?.message || "Failed to create artifact"}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => !createArtifactM.isPending && setShowNewArtifact(false)}
disabled={createArtifactM.isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={createArtifactM.isPending}>
{createArtifactM.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{createArtifactM.isPending ? "Creating…" : "Create Artifact"}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Add Relationship Modal ---------- */}
{showNewRel && (
<div
className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center p-4 pt-16 overflow-y-auto"
onClick={() => !addRelM.isPending && setShowNewRel(false)}
>
<div
className="bg-white w-full max-w-2xl rounded-2xl shadow-xl border border-slate-200 max-h-[90vh] overflow-hidden flex flex-col my-8"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 px-6 py-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<ArrowRightLeft className="w-5 h-5 text-brand-600" /> Add Relationship
</h3>
<p className="mt-1 text-sm text-slate-500">
Link two artifacts with one of 12 relationship types. Source → Target.
</p>
</div>
</div>
<form
className="p-6 space-y-4 overflow-y-auto"
onSubmit={(e) => {
e.preventDefault();
void addRelM.mutateAsync();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Source Artifact *</label>
<select
className="asf-input"
value={nrSourceId}
onChange={(e) => setNrSourceId(e.target.value)}
>
<option value="">— select source —</option>
{(listQ.data ?? rows).map((a) => (
<option key={String(a.id)} value={String(a.id)}>
[{a.type}] {a.external_id ?? shortId(String(a.id))} — {truncate(a.title, 42)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Relationship Type *</label>
<select
className="asf-input"
value={nrRelType}
onChange={(e) => setNrRelType(e.target.value as typeof RELATIONSHIP_TYPES[number])}
>
{RELATIONSHIP_TYPES.map((t) => (
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">Target Artifact *</label>
<div className="space-y-2">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Filter target artifacts…"
value={nrTargetSearch}
onChange={(e) => setNrTargetSearch(e.target.value)}
/>
</div>
<div className="max-h-52 overflow-auto border border-slate-200 rounded-lg divide-y divide-slate-100">
{targetCandidates.length === 0 && (
<div className="p-4 text-sm text-slate-500 text-center">No candidates. Try another filter.</div>
)}
{targetCandidates.map((a) => {
const active = nrTargetId === String(a.id);
return (
<button
type="button"
key={String(a.id)}
onClick={() => setNrTargetId(String(a.id))}
className={cn(
"w-full text-left px-3 py-2 flex items-start gap-2 text-sm hover:bg-slate-50 transition-colors",
active && "bg-brand-50 ring-1 ring-brand-200",
)}
>
<input
type="radio"
className="mt-1"
checked={active}
onChange={() => setNrTargetId(String(a.id))}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<ArtifactTypeBadge type={a.type as ArtifactType} />
<span className="asf-monospace text-[11px] text-slate-500">
{a.external_id ?? shortId(String(a.id))}
</span>
</div>
<div className="text-slate-800 font-medium truncate">{a.title}</div>
</div>
</button>
);
})}
</div>
</div>
</div>
</div>
{addRelM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{(addRelM.error as Error)?.message || "Failed to create relationship"}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => !addRelM.isPending && setShowNewRel(false)}
disabled={addRelM.isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={addRelM.isPending}>
{addRelM.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{addRelM.isPending ? "Linking…" : "Create Relationship"}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Delete Artifact Confirm Modal ---------- */}
{showDelArtifact && (
<div
className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => !deleteArtifactM.isPending && setShowDelArtifact(false)}
>
<div
className="bg-white w-full max-w-md rounded-2xl shadow-xl border border-slate-200 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-slate-100 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-slate-900">Delete artifact?</h3>
<p className="mt-1 text-sm text-slate-500">
{selected && (
<>
Permanently delete <span className="font-semibold text-slate-700">{selected.title}</span>{" "}
<span className="asf-monospace text-xs">({selected.external_id ?? shortId(String(selected.id))})</span>.
All incoming and outgoing relationships will also be removed. This action cannot be undone.
</>
)}
</p>
</div>
</div>
{deleteArtifactM.isError && (
<div className="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{(deleteArtifactM.error as Error)?.message || "Failed to delete artifact"}
</div>
)}
<div className="px-6 py-4 flex items-center justify-end gap-2 bg-slate-50 border-t border-slate-100">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => !deleteArtifactM.isPending && setShowDelArtifact(false)}
disabled={deleteArtifactM.isPending}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
size="sm"
className="!bg-red-600 hover:!bg-red-700 !text-white"
onClick={() => void deleteArtifactM.mutateAsync()}
disabled={deleteArtifactM.isPending}
>
{deleteArtifactM.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{deleteArtifactM.isPending ? "Deleting…" : "Delete forever"}
</Button>
</div>
</div>
</div>
)}
{/* ---------- Edit Artifact Modal ---------- */}
{showEditArtifact && (
<div
className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center p-4 pt-16 overflow-y-auto"
onClick={() => !editArtifactM.isPending && setShowEditArtifact(false)}
>
<div
className="bg-white w-full max-w-2xl rounded-2xl shadow-xl border border-slate-200 max-h-[90vh] overflow-hidden flex flex-col my-8"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 bg-white border-b border-slate-100 px-6 py-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Pencil className="w-5 h-5 text-brand-600" /> Edit Artifact
</h3>
<p className="mt-1 text-sm text-slate-500">
Update any field — changes are persisted via PATCH.
</p>
</div>
</div>
<form
className="p-6 space-y-4 overflow-y-auto"
onSubmit={(e) => {
e.preventDefault();
void editArtifactM.mutateAsync();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Type *</label>
<select
className="asf-input"
value={eaType}
onChange={(e) => setEaType(e.target.value as ArtifactType)}
>
{ARTIFACT_TYPES.map((t) => (
<option key={t} value={t}>{t.replace(/_/g, " ")}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Status</label>
<input
className="asf-input"
placeholder="backlog, todo, done, fixed…"
value={eaStatus}
onChange={(e) => setEaStatus(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">Title *</label>
<input
className="asf-input"
placeholder="Short descriptive title"
value={eaTitle}
onChange={(e) => setEaTitle(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">External ID</label>
<input
className="asf-input asf-monospace"
placeholder="REQ-401, PR#99, TICKET-12…"
value={eaExternalId}
onChange={(e) => setEaExternalId(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Project *</label>
<select
className="asf-input"
value={eaProjectId}
onChange={(e) => setEaProjectId(e.target.value)}
>
<option value="">— select project —</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{p.key ? `[${p.key}] ` : ""}{p.name ?? shortId(String(p.id))}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Source</label>
<input
className="asf-input"
placeholder="manual, jira, github, ci…"
value={eaSource}
onChange={(e) => setEaSource(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">Content / Description</label>
<textarea
className="asf-input min-h-[96px] resize-y"
rows={4}
placeholder="Full body, description, acceptance criteria…"
value={eaContent}
onChange={(e) => setEaContent(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Metadata (JSON)
</label>
<textarea
className={cn(
"asf-input asf-monospace text-xs min-h-[100px] resize-y",
eaMetaError && "border-red-300 focus:ring-red-200",
)}
rows={4}
value={eaMetaText}
onChange={(e) => setEaMetaText(e.target.value)}
placeholder='{ "kind": "code_smell", "severity": "high" }'
/>
{eaMetaError && (
<p className="mt-1 text-xs text-red-600">{eaMetaError}</p>
)}
</div>
</div>
{editArtifactM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{(editArtifactM.error as Error)?.message || "Failed to update artifact"}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => !editArtifactM.isPending && setShowEditArtifact(false)}
disabled={editArtifactM.isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={editArtifactM.isPending}>
{editArtifactM.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{editArtifactM.isPending ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}