feat(dashboard): layout-issue banner + Computing layout overlay

This commit is contained in:
Lum1104
2026-05-03 17:01:41 +08:00
Unverified
parent 69a0cd13a9
commit 1329c93dbd
5 changed files with 162 additions and 36 deletions
@@ -117,6 +117,13 @@ function Dashboard({ accessToken }: { accessToken: string }) {
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const setDomainGraph = useDashboardStore((s) => s.setDomainGraph);
const layoutIssues = useDashboardStore((s) => s.layoutIssues);
// Schema issues + ELK layout issues share the WarningBanner — graph-load
// problems and dashboard rendering problems are equally surfaced.
const allIssues = useMemo(
() => [...graphIssues, ...layoutIssues],
[graphIssues, layoutIssues],
);
useEffect(() => {
fetch(dataUrl("meta.json", accessToken))
@@ -506,8 +513,8 @@ function Dashboard({ accessToken }: { accessToken: string }) {
<SearchBar />
{/* Validation warning banner */}
{graphIssues.length > 0 && !loadError && (
<WarningBanner issues={graphIssues} />
{allIssues.length > 0 && !loadError && (
<WarningBanner issues={allIssues} />
)}
{/* Error banner */}
@@ -199,8 +199,8 @@ function DomainGraphViewInner() {
.then(({ positioned, issues }) => {
if (cancelled) return;
if (issues.length > 0) {
// TODO: Task 16 funnels these into the WarningBanner.
console.warn("[domain ELK] layout issues:", issues);
// Funnel into store so WarningBanner surfaces them.
useDashboardStore.getState().appendLayoutIssues(issues);
}
setLayout({
nodes: mergeElkPositions(nodesArray, positioned),
@@ -228,37 +228,42 @@ function useOverviewGraph() {
nodes: [],
edges: [],
});
const [layoutStatus, setLayoutStatus] = useState<"computing" | "ready">("ready");
useEffect(() => {
if (!built) {
setOverview({ nodes: [], edges: [] });
setLayoutStatus("ready");
return;
}
let cancelled = false;
const { clusterNodes, flowEdges, dims } = built;
const baseNodes = clusterNodes as unknown as Node[];
const elkInput = nodesToElkInput(baseNodes, flowEdges, dims);
setLayoutStatus("computing");
applyElkLayout(elkInput, { strict: import.meta.env.DEV })
.then(({ positioned, issues }) => {
if (cancelled) return;
if (issues.length > 0) {
// TODO: Task 16 wires these into the WarningBanner. Until then,
// surface them in the console so they aren't completely silent.
console.warn("[overview ELK] layout issues:", issues);
// Funnel into store so WarningBanner surfaces them. getState()
// avoids re-creating the closure on every layoutIssues change.
useDashboardStore.getState().appendLayoutIssues(issues);
}
const positionedNodes = mergeElkPositions(baseNodes, positioned);
setOverview({ nodes: positionedNodes, edges: flowEdges });
setLayoutStatus("ready");
})
.catch((err) => {
if (cancelled) return;
console.error("[overview ELK] layout failed:", err);
setLayoutStatus("ready");
});
return () => {
cancelled = true;
};
}, [built]);
return overview;
return { ...overview, layoutStatus };
}
// ── Layer detail level: topology (ELK Stage 1) + visual overlay ─────────
@@ -295,7 +300,9 @@ const EMPTY_TOPOLOGY: LayerDetailTopology = {
* selectedNodeId, searchResults, tourHighlightedNodeIds, or
* expandedContainers (Stage 2 concern).
*/
function useLayerDetailTopology(): LayerDetailTopology {
function useLayerDetailTopology(): LayerDetailTopology & {
layoutStatus: "computing" | "ready";
} {
const graph = useDashboardStore((s) => s.graph);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const selectNode = useDashboardStore((s) => s.selectNode);
@@ -575,10 +582,12 @@ function useLayerDetailTopology(): LayerDetailTopology {
// surrounding atoms reflow into the correct positions.
const stage1Tick = useDashboardStore((s) => s.stage1Tick);
const [topology, setTopology] = useState<LayerDetailTopology>(EMPTY_TOPOLOGY);
const [layoutStatus, setLayoutStatus] = useState<"computing" | "ready">("ready");
useEffect(() => {
if (!built) {
setTopology(EMPTY_TOPOLOGY);
setLayoutStatus("ready");
return;
}
let cancelled = false;
@@ -646,12 +655,13 @@ function useLayerDetailTopology(): LayerDetailTopology {
edges: stage1Edges,
};
setLayoutStatus("computing");
applyElkLayout(elkInput, { strict: import.meta.env.DEV })
.then(({ positioned, issues }) => {
if (cancelled) return;
if (issues.length > 0) {
// TODO: Task 16 wires these into the WarningBanner.
console.warn("[layer-detail Stage 1 ELK] layout issues:", issues);
// Funnel into store so WarningBanner surfaces them.
useDashboardStore.getState().appendLayoutIssues(issues);
}
const allBaseNodes: Node[] = [
...(containerFlowNodes as unknown as Node[]),
@@ -670,10 +680,12 @@ function useLayerDetailTopology(): LayerDetailTopology {
nodeToContainer,
intraContainer,
});
setLayoutStatus("ready");
})
.catch((err) => {
if (cancelled) return;
console.error("[layer-detail Stage 1 ELK] layout failed:", err);
setLayoutStatus("ready");
});
return () => {
@@ -736,7 +748,8 @@ function useLayerDetailTopology(): LayerDetailTopology {
strict: import.meta.env.DEV,
});
if (issues.length > 0) {
console.warn(`[Stage 2 ${containerId}] issues:`, issues);
// Funnel into store so WarningBanner surfaces them.
useDashboardStore.getState().appendLayoutIssues(issues);
}
const childPositions = new Map<string, { x: number; y: number }>();
let maxX = 0;
@@ -806,7 +819,7 @@ function useLayerDetailTopology(): LayerDetailTopology {
bumpStage1Tick,
]);
return topology;
return { ...topology, layoutStatus };
}
/**
@@ -1157,7 +1170,13 @@ function useLayerDetailGraph() {
[topo.containers],
);
return { nodes, edges, nodeToContainer: topo.nodeToContainer, containerIds };
return {
nodes,
edges,
nodeToContainer: topo.nodeToContainer,
containerIds,
layoutStatus: topo.layoutStatus,
};
}
// ── Main inner component (must be inside ReactFlowProvider) ────────────
@@ -1183,6 +1202,7 @@ function GraphViewInner() {
edges: initialEdges,
nodeToContainer,
containerIds,
layoutStatus,
} = navigationLevel === "overview"
? { ...overviewGraph, nodeToContainer: undefined, containerIds: undefined }
: detailGraph;
@@ -1347,6 +1367,22 @@ function GraphViewInner() {
<TourFitView />
<SelectedNodeFitView />
</ReactFlow>
{layoutStatus === "computing" && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(10,10,10,0.5)",
pointerEvents: "none",
zIndex: 10,
}}
>
<span style={{ color: "#d4a574", fontSize: 14 }}>Computing layout</span>
</div>
)}
</div>
);
}
@@ -6,17 +6,27 @@ interface WarningBannerProps {
}
function buildCopyText(issues: GraphIssue[]): string {
const lines = [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
const hasFatal = issues.some((i) => i.level === "fatal");
// Fatal issues are dashboard rendering bugs (e.g. ELK layout failures), not
// LLM generation errors — route the user to file a bug report instead of
// asking their agent to "fix" the knowledge-graph.json.
const lines = hasFatal
? [
"Some of these issues look like dashboard rendering bugs.",
"Please file an issue at github.com/Lum1104/Understand-Anything/issues with the text below.",
"",
]
: [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
// Auto-corrected first, then dropped
// Show fatal first (most actionable for bug reports), then dropped, then auto-corrected.
const sorted = [...issues].sort((a, b) => {
const order: Record<string, number> = { "auto-corrected": 0, dropped: 1, fatal: 2 };
return (order[a.level] ?? 2) - (order[b.level] ?? 2);
const order: Record<string, number> = { fatal: 0, dropped: 1, "auto-corrected": 2 };
return (order[a.level] ?? 3) - (order[b.level] ?? 3);
});
for (const issue of sorted) {
@@ -36,18 +46,25 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const fatal = issues.filter((i) => i.level === "fatal");
const autoCorrected = issues.filter((i) => i.level === "auto-corrected");
const dropped = issues.filter((i) => i.level === "dropped");
const hasFatal = fatal.length > 0;
// Build summary text — only mention counts > 0
const parts: string[] = [];
if (fatal.length > 0) {
parts.push(`${fatal.length} fatal error${fatal.length !== 1 ? "s" : ""}`);
}
if (autoCorrected.length > 0) {
parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`);
}
if (dropped.length > 0) {
parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`);
}
const summary = `Knowledge graph loaded with ${parts.join(" and ")}`;
const summary = hasFatal
? `Dashboard hit ${parts.join(", ")}`
: `Knowledge graph loaded with ${parts.join(" and ")}`;
const handleCopy = useCallback(async () => {
const text = buildCopyText(issues);
@@ -62,18 +79,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
if (issues.length === 0) return null;
// Fatal issues escalate the banner from amber (warning) to red (error).
const containerClasses = hasFatal
? "bg-red-900/25 border-b border-red-700 text-red-200 text-sm"
: "bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm";
const hoverClasses = hasFatal
? "hover:bg-red-900/15"
: "hover:bg-amber-900/10";
const iconClasses = hasFatal ? "text-red-400" : "text-amber-400";
const hintClasses = hasFatal ? "text-red-400/60" : "text-amber-400/60";
const dividerClasses = hasFatal ? "border-red-700/50" : "border-amber-700/50";
const footerTextClasses = hasFatal ? "text-red-200/70" : "text-amber-200/60";
const buttonClasses = hasFatal
? "bg-red-800/40 text-red-200 hover:bg-red-800/60"
: "bg-amber-800/40 text-amber-200 hover:bg-amber-800/60";
const footerCopy = hasFatal
? "Copy these issues and file a bug report on GitHub"
: "Copy these issues and ask your agent to fix them in knowledge-graph.json";
return (
<div className="bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm">
<div className={containerClasses}>
{/* Collapsed summary row */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
className="w-full flex items-center gap-2 px-5 py-3 text-left hover:bg-amber-900/10 transition-colors"
className={`w-full flex items-center gap-2 px-5 py-3 text-left transition-colors ${hoverClasses}`}
>
{/* Chevron icon */}
<svg
className={`w-4 h-4 shrink-0 text-amber-400 transition-transform duration-200 ${
className={`w-4 h-4 shrink-0 ${iconClasses} transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
fill="none"
@@ -90,7 +125,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Warning icon */}
<svg
className="w-4 h-4 shrink-0 text-amber-400"
className={`w-4 h-4 shrink-0 ${iconClasses}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -105,7 +140,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
<span className="flex-1">{summary}</span>
<span className="text-amber-400/60 text-xs shrink-0">
<span className={`text-xs shrink-0 ${hintClasses}`}>
{expanded ? "click to collapse" : "click to expand"}
</span>
</button>
@@ -115,9 +150,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
<div className="px-5 pb-4">
{/* Issue list */}
<div className="space-y-1 mb-3">
{/* Fatal issues — top of list, red, most prominent */}
{fatal.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-400 mb-1">
Fatal ({fatal.length})
</h4>
{fatal.map((issue, i) => (
<div
key={`ft-${i}`}
className="flex items-start gap-2 py-0.5 pl-2 text-red-200"
>
<span className="text-red-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
{/* Auto-corrected issues */}
{autoCorrected.length > 0 && (
<div>
<div className={fatal.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-amber-400 mb-1">
Auto-corrected ({autoCorrected.length})
</h4>
@@ -136,7 +198,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Dropped issues */}
{dropped.length > 0 && (
<div className={autoCorrected.length > 0 ? "mt-2" : ""}>
<div className={fatal.length > 0 || autoCorrected.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-400 mb-1">
Dropped ({dropped.length})
</h4>
@@ -155,14 +217,12 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
</div>
{/* Footer with copy button and actionable message */}
<div className="flex items-center justify-between pt-2 border-t border-amber-700/50">
<p className="text-xs text-amber-200/60">
Copy these issues and ask your agent to fix them in knowledge-graph.json
</p>
<div className={`flex items-center justify-between pt-2 border-t ${dividerClasses}`}>
<p className={`text-xs ${footerTextClasses}`}>{footerCopy}</p>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-amber-800/40 text-amber-200 hover:bg-amber-800/60 transition-colors shrink-0 ml-4"
className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition-colors shrink-0 ml-4 ${buttonClasses}`}
>
{copied ? (
<>
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { SearchEngine } from "@understand-anything/core/search";
import type { SearchResult } from "@understand-anything/core/search";
import type { GraphIssue } from "@understand-anything/core/schema";
import type {
KnowledgeGraph,
TourStep,
@@ -173,6 +174,12 @@ interface DashboardStore {
stage1Tick: number;
bumpStage1Tick: () => void;
// Layout-time issues (e.g. ELK input repair). Funneled into the
// WarningBanner alongside graph-validation issues.
layoutIssues: GraphIssue[];
appendLayoutIssues: (issues: GraphIssue[]) => void;
clearLayoutIssues: () => void;
}
function getSortedTour(graph: KnowledgeGraph): TourStep[] {
@@ -261,6 +268,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
expandedContainers: new Set(),
containerSizeMemory: new Map(),
stage1Tick: 0,
layoutIssues: [],
});
},
@@ -584,4 +592,19 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
stage1Tick: 0,
bumpStage1Tick: () => set((s) => ({ stage1Tick: s.stage1Tick + 1 })),
layoutIssues: [],
appendLayoutIssues: (issues) =>
set((state) => {
if (issues.length === 0) return {};
// Dedupe by level+message so a re-running effect doesn't repeatedly
// pile up identical issues.
const seen = new Set(
state.layoutIssues.map((i) => `${i.level}|${i.message}`),
);
const fresh = issues.filter((i) => !seen.has(`${i.level}|${i.message}`));
if (fresh.length === 0) return {};
return { layoutIssues: [...state.layoutIssues, ...fresh] };
}),
clearLayoutIssues: () => set({ layoutIssues: [] }),
}));