1640 lines
72 KiB
TypeScript
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>
|
|
);
|
|
}
|