create-project-arch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/cli.js +232 -0
- package/dist/cli.test.js +8 -0
- package/package.json +29 -0
- package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
- package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
- package/templates/arch-ui/.arch/graph.json +17 -0
- package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
- package/templates/arch-ui/.arch/nodes/domains.json +4 -0
- package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
- package/templates/arch-ui/.arch/nodes/modules.json +4 -0
- package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
- package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
- package/templates/arch-ui/app/api/decisions/route.ts +23 -0
- package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
- package/templates/arch-ui/app/api/domains/route.ts +10 -0
- package/templates/arch-ui/app/api/graph/route.ts +16 -0
- package/templates/arch-ui/app/api/health/route.ts +44 -0
- package/templates/arch-ui/app/api/node-files/route.ts +173 -0
- package/templates/arch-ui/app/api/phases/route.ts +10 -0
- package/templates/arch-ui/app/api/route.ts +22 -0
- package/templates/arch-ui/app/api/search/route.ts +56 -0
- package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
- package/templates/arch-ui/app/api/tasks/route.ts +36 -0
- package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
- package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
- package/templates/arch-ui/app/architecture/page.tsx +5 -0
- package/templates/arch-ui/app/globals.css +240 -0
- package/templates/arch-ui/app/health/page.tsx +48 -0
- package/templates/arch-ui/app/layout.tsx +19 -0
- package/templates/arch-ui/app/page.tsx +5 -0
- package/templates/arch-ui/app/work/page.tsx +265 -0
- package/templates/arch-ui/components/app-shell.tsx +171 -0
- package/templates/arch-ui/components/error-boundary.tsx +53 -0
- package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
- package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
- package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
- package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
- package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
- package/templates/arch-ui/components/graph/graph-types.ts +82 -0
- package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
- package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
- package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
- package/templates/arch-ui/components/graph-canvas.tsx +670 -0
- package/templates/arch-ui/components/health-panel.tsx +49 -0
- package/templates/arch-ui/components/inspector-context.tsx +35 -0
- package/templates/arch-ui/components/inspector.tsx +895 -0
- package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
- package/templates/arch-ui/components/sidebar.tsx +531 -0
- package/templates/arch-ui/components/topbar.tsx +187 -0
- package/templates/arch-ui/components/work-table.tsx +57 -0
- package/templates/arch-ui/components/workspace-context.tsx +274 -0
- package/templates/arch-ui/eslint.config.js +2 -0
- package/templates/arch-ui/global.d.ts +1 -0
- package/templates/arch-ui/lib/api.ts +93 -0
- package/templates/arch-ui/lib/arch-model.ts +113 -0
- package/templates/arch-ui/lib/graph-dataset.ts +756 -0
- package/templates/arch-ui/lib/graph-schema.ts +408 -0
- package/templates/arch-ui/lib/project-root.ts +52 -0
- package/templates/arch-ui/lib/types.ts +116 -0
- package/templates/arch-ui/next-env.d.ts +6 -0
- package/templates/arch-ui/next.config.js +17 -0
- package/templates/arch-ui/package.json +38 -0
- package/templates/arch-ui/postcss.config.mjs +6 -0
- package/templates/arch-ui/tailwind.config.ts +11 -0
- package/templates/arch-ui/tsconfig.json +21 -0
- package/templates/ui-package/eslint.config.mjs +4 -0
- package/templates/ui-package/package.json +26 -0
- package/templates/ui-package/src/accordion.tsx +10 -0
- package/templates/ui-package/src/badge.tsx +12 -0
- package/templates/ui-package/src/button.tsx +32 -0
- package/templates/ui-package/src/card.tsx +22 -0
- package/templates/ui-package/src/code.tsx +6 -0
- package/templates/ui-package/src/command.tsx +18 -0
- package/templates/ui-package/src/dialog.tsx +6 -0
- package/templates/ui-package/src/dropdown-menu.tsx +10 -0
- package/templates/ui-package/src/input.tsx +6 -0
- package/templates/ui-package/src/navigation-menu.tsx +6 -0
- package/templates/ui-package/src/scroll-area.tsx +6 -0
- package/templates/ui-package/src/select.tsx +6 -0
- package/templates/ui-package/src/separator.tsx +6 -0
- package/templates/ui-package/src/sheet.tsx +6 -0
- package/templates/ui-package/src/skeleton.tsx +6 -0
- package/templates/ui-package/src/table.tsx +26 -0
- package/templates/ui-package/src/tabs.tsx +14 -0
- package/templates/ui-package/src/toggle-group.tsx +10 -0
- package/templates/ui-package/src/utils.ts +3 -0
- package/templates/ui-package/tsconfig.json +10 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@repo/ui/badge";
|
|
4
|
+
import { Button } from "@repo/ui/button";
|
|
5
|
+
import { Code } from "@repo/ui/code";
|
|
6
|
+
import { Separator } from "@repo/ui/separator";
|
|
7
|
+
import { ReactNode, useEffect, useMemo, useState } from "react";
|
|
8
|
+
import { getArchitectureMap, getGraphDataset, getNodeFiles, getTaskTrace } from "../lib/api";
|
|
9
|
+
import { ArchitectureMapData, GraphValidationIssueData } from "../lib/types";
|
|
10
|
+
import { MarkdownViewer } from "./markdown-viewer";
|
|
11
|
+
import { useInspector } from "./inspector-context";
|
|
12
|
+
import { useWorkspace } from "./workspace-context";
|
|
13
|
+
|
|
14
|
+
type TracePayload = {
|
|
15
|
+
task?: string;
|
|
16
|
+
decisionRefs?: string[];
|
|
17
|
+
moduleRefs?: string[];
|
|
18
|
+
files?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EntityDetails = {
|
|
22
|
+
summary: Array<{ label: string; value: string }>;
|
|
23
|
+
relatedTasks: string[];
|
|
24
|
+
relatedMilestones: string[];
|
|
25
|
+
relatedDecisions: string[];
|
|
26
|
+
relatedModules: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type MetadataRecord = {
|
|
30
|
+
label: string;
|
|
31
|
+
value: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type FrontmatterValue = string | string[] | null;
|
|
35
|
+
type FrontmatterData = Record<string, FrontmatterValue>;
|
|
36
|
+
|
|
37
|
+
function parseFrontmatter(markdown: string): FrontmatterData {
|
|
38
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
39
|
+
if (!normalized.startsWith("---\n")) return {};
|
|
40
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
41
|
+
if (end === -1) return {};
|
|
42
|
+
|
|
43
|
+
const body = normalized.slice(4, end);
|
|
44
|
+
const lines = body.split("\n");
|
|
45
|
+
const entries: FrontmatterData = {};
|
|
46
|
+
let currentKey: string | null = null;
|
|
47
|
+
let arrayValues: string[] = [];
|
|
48
|
+
|
|
49
|
+
function flushArray() {
|
|
50
|
+
if (!currentKey) return;
|
|
51
|
+
entries[currentKey] = arrayValues;
|
|
52
|
+
currentKey = null;
|
|
53
|
+
arrayValues = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeScalar(value: string): FrontmatterValue {
|
|
57
|
+
const unquoted = value.replace(/^['"]|['"]$/g, "").trim();
|
|
58
|
+
if (unquoted.toLowerCase() === "null") return null;
|
|
59
|
+
if (unquoted === "[]") return [];
|
|
60
|
+
if (/^\[.*\]$/.test(unquoted)) {
|
|
61
|
+
const inner = unquoted.slice(1, -1).trim();
|
|
62
|
+
if (!inner) return [];
|
|
63
|
+
return inner
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((part) => part.trim().replace(/^['"]|['"]$/g, ""))
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
return unquoted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const rawLine of lines) {
|
|
72
|
+
const line = rawLine.trimEnd();
|
|
73
|
+
if (!line.trim()) continue;
|
|
74
|
+
|
|
75
|
+
const arrayMatch = line.match(/^\s*-\s+(.*)$/);
|
|
76
|
+
if (arrayMatch && currentKey) {
|
|
77
|
+
const normalizedItem = normalizeScalar(arrayMatch[1]?.trim() ?? "");
|
|
78
|
+
if (typeof normalizedItem === "string" && normalizedItem.length > 0) {
|
|
79
|
+
arrayValues.push(normalizedItem);
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
flushArray();
|
|
85
|
+
const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
86
|
+
if (!keyValueMatch) continue;
|
|
87
|
+
|
|
88
|
+
const key = keyValueMatch[1] ?? "";
|
|
89
|
+
const rawValue = (keyValueMatch[2] ?? "").trim();
|
|
90
|
+
if (rawValue === "") {
|
|
91
|
+
currentKey = key;
|
|
92
|
+
arrayValues = [];
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
entries[key] = normalizeScalar(rawValue);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
flushArray();
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stripFrontmatter(markdown: string): string {
|
|
103
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
104
|
+
if (!normalized.startsWith("---\n")) {
|
|
105
|
+
return markdown;
|
|
106
|
+
}
|
|
107
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
108
|
+
if (end === -1) {
|
|
109
|
+
return markdown;
|
|
110
|
+
}
|
|
111
|
+
return normalized.slice(end + 5).trimStart();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function uniqueSorted(values: string[]): string[] {
|
|
115
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isTaskLikeType(type: string): type is "task" | "milestone" | "phase" {
|
|
119
|
+
return type === "task" || type === "milestone" || type === "phase";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isFileBackedType(type: string): type is "task" | "milestone" | "phase" | "domain" | "file" {
|
|
123
|
+
return (
|
|
124
|
+
type === "task" ||
|
|
125
|
+
type === "milestone" ||
|
|
126
|
+
type === "phase" ||
|
|
127
|
+
type === "domain" ||
|
|
128
|
+
type === "file"
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeKey(label: string): string {
|
|
133
|
+
return label.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function titleCase(value: string): string {
|
|
137
|
+
return value
|
|
138
|
+
.split(/\s+/)
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
|
|
141
|
+
.join(" ");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function humanizeSlug(slug: string): string {
|
|
145
|
+
if (!slug) return "n/a";
|
|
146
|
+
const converted = slug
|
|
147
|
+
.replace(/[_-]+/g, " ")
|
|
148
|
+
.replace(/\b(\d+)\b/g, "$1")
|
|
149
|
+
.trim();
|
|
150
|
+
return titleCase(converted);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function humanizeLabel(label: string): string {
|
|
154
|
+
const cleaned = label.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
|
|
155
|
+
return titleCase(cleaned);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function humanizeValue(label: string, value: string): string {
|
|
159
|
+
if (!value) return "n/a";
|
|
160
|
+
|
|
161
|
+
const key = normalizeKey(label);
|
|
162
|
+
if (key === "lane" || key === "status") {
|
|
163
|
+
if (value.toLowerCase() === "todo") return "To Do";
|
|
164
|
+
return humanizeSlug(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (key === "domain") {
|
|
168
|
+
return value === "unassigned" ? "Unassigned" : humanizeSlug(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (key.includes("count")) {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (key === "graphnode") {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (value.includes("/")) {
|
|
180
|
+
return value
|
|
181
|
+
.split("/")
|
|
182
|
+
.map((segment) => humanizeSlug(segment))
|
|
183
|
+
.join(" > ");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (/^[a-z0-9-]+$/.test(value)) {
|
|
187
|
+
return humanizeSlug(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseTaskId(taskId: string): { phaseId?: string; milestoneId?: string; taskNumber?: string } {
|
|
194
|
+
const [phaseId, milestoneId, taskNumber] = taskId.split("/");
|
|
195
|
+
return {
|
|
196
|
+
phaseId: phaseId || undefined,
|
|
197
|
+
milestoneId: milestoneId || undefined,
|
|
198
|
+
taskNumber: taskNumber || undefined,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isDateLikeValue(value: string): boolean {
|
|
203
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatDateValue(value: string): string {
|
|
207
|
+
if (!isDateLikeValue(value)) return value;
|
|
208
|
+
const parsed = new Date(`${value}T00:00:00`);
|
|
209
|
+
if (Number.isNaN(parsed.getTime())) return value;
|
|
210
|
+
return parsed.toLocaleDateString("en-US", {
|
|
211
|
+
year: "numeric",
|
|
212
|
+
month: "short",
|
|
213
|
+
day: "numeric",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createMetadataMap(entries: MetadataRecord[]): Map<string, MetadataRecord> {
|
|
218
|
+
const map = new Map<string, MetadataRecord>();
|
|
219
|
+
entries.forEach((entry) => {
|
|
220
|
+
const key = normalizeKey(entry.label);
|
|
221
|
+
if (!map.has(key) && entry.value) {
|
|
222
|
+
map.set(key, entry);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return map;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getMetadataValue(map: Map<string, MetadataRecord>, ...keys: string[]): string | undefined {
|
|
229
|
+
for (const key of keys) {
|
|
230
|
+
const entry = map.get(normalizeKey(key));
|
|
231
|
+
if (entry?.value) return entry.value;
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function deriveEntityDetails(
|
|
237
|
+
map: ArchitectureMapData,
|
|
238
|
+
selectionType: "task" | "milestone" | "phase",
|
|
239
|
+
selectionId: string,
|
|
240
|
+
): EntityDetails {
|
|
241
|
+
if (selectionType === "task") {
|
|
242
|
+
const task = map.nodes.tasks.find((item) => item.id === selectionId);
|
|
243
|
+
const milestone = task ? map.nodes.milestones.find((m) => m.id === task.milestone) : null;
|
|
244
|
+
const decisions = uniqueSorted(
|
|
245
|
+
map.edges.taskToDecision
|
|
246
|
+
.filter((edge) => edge.task === selectionId)
|
|
247
|
+
.map((edge) => edge.decision),
|
|
248
|
+
);
|
|
249
|
+
const modules = uniqueSorted(
|
|
250
|
+
map.edges.taskToModule.filter((edge) => edge.task === selectionId).map((edge) => edge.module),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
summary: [
|
|
255
|
+
{ label: "Task ID", value: selectionId },
|
|
256
|
+
{ label: "Title", value: task?.title ?? "n/a" },
|
|
257
|
+
{ label: "Lane", value: task?.lane ?? "n/a" },
|
|
258
|
+
{ label: "Status", value: task?.status ?? "n/a" },
|
|
259
|
+
{ label: "Domain", value: task?.domain ?? "unassigned" },
|
|
260
|
+
{ label: "Milestone", value: task?.milestone ?? "n/a" },
|
|
261
|
+
{ label: "Phase", value: milestone?.phaseId ?? "n/a" },
|
|
262
|
+
],
|
|
263
|
+
relatedTasks: [selectionId],
|
|
264
|
+
relatedMilestones: uniqueSorted(task?.milestone ? [task.milestone] : []),
|
|
265
|
+
relatedDecisions: decisions,
|
|
266
|
+
relatedModules: modules,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (selectionType === "milestone") {
|
|
271
|
+
const milestone = map.nodes.milestones.find((item) => item.id === selectionId);
|
|
272
|
+
const taskIds = uniqueSorted(
|
|
273
|
+
map.edges.milestoneToTask
|
|
274
|
+
.filter((edge) => edge.milestone === selectionId)
|
|
275
|
+
.map((edge) => edge.task),
|
|
276
|
+
);
|
|
277
|
+
const taskIdSet = new Set(taskIds);
|
|
278
|
+
const decisions = uniqueSorted(
|
|
279
|
+
map.edges.taskToDecision
|
|
280
|
+
.filter((edge) => taskIdSet.has(edge.task))
|
|
281
|
+
.map((edge) => edge.decision),
|
|
282
|
+
);
|
|
283
|
+
const modules = uniqueSorted(
|
|
284
|
+
map.edges.taskToModule.filter((edge) => taskIdSet.has(edge.task)).map((edge) => edge.module),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
summary: [
|
|
289
|
+
{ label: "Milestone ID", value: selectionId },
|
|
290
|
+
{ label: "Phase", value: milestone?.phaseId ?? "n/a" },
|
|
291
|
+
{ label: "Milestone Slug", value: milestone?.milestoneId ?? "n/a" },
|
|
292
|
+
{ label: "Task Count", value: String(taskIds.length) },
|
|
293
|
+
{ label: "Decision Count", value: String(decisions.length) },
|
|
294
|
+
{ label: "Module Count", value: String(modules.length) },
|
|
295
|
+
],
|
|
296
|
+
relatedTasks: taskIds,
|
|
297
|
+
relatedMilestones: [selectionId],
|
|
298
|
+
relatedDecisions: decisions,
|
|
299
|
+
relatedModules: modules,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const phaseMilestones = uniqueSorted(
|
|
304
|
+
map.nodes.milestones.filter((item) => item.phaseId === selectionId).map((item) => item.id),
|
|
305
|
+
);
|
|
306
|
+
const milestoneSet = new Set(phaseMilestones);
|
|
307
|
+
const taskIds = uniqueSorted(
|
|
308
|
+
map.edges.milestoneToTask
|
|
309
|
+
.filter((edge) => milestoneSet.has(edge.milestone))
|
|
310
|
+
.map((edge) => edge.task),
|
|
311
|
+
);
|
|
312
|
+
const taskIdSet = new Set(taskIds);
|
|
313
|
+
const decisions = uniqueSorted(
|
|
314
|
+
map.edges.taskToDecision
|
|
315
|
+
.filter((edge) => taskIdSet.has(edge.task))
|
|
316
|
+
.map((edge) => edge.decision),
|
|
317
|
+
);
|
|
318
|
+
const modules = uniqueSorted(
|
|
319
|
+
map.edges.taskToModule.filter((edge) => taskIdSet.has(edge.task)).map((edge) => edge.module),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
summary: [
|
|
324
|
+
{ label: "Phase ID", value: selectionId },
|
|
325
|
+
{ label: "Milestone Count", value: String(phaseMilestones.length) },
|
|
326
|
+
{ label: "Task Count", value: String(taskIds.length) },
|
|
327
|
+
{ label: "Decision Count", value: String(decisions.length) },
|
|
328
|
+
{ label: "Module Count", value: String(modules.length) },
|
|
329
|
+
],
|
|
330
|
+
relatedTasks: taskIds,
|
|
331
|
+
relatedMilestones: phaseMilestones,
|
|
332
|
+
relatedDecisions: decisions,
|
|
333
|
+
relatedModules: modules,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function Field({ label, value }: { label: string; value: string }) {
|
|
338
|
+
return (
|
|
339
|
+
<p className="m-0 leading-tight">
|
|
340
|
+
<span className="text-slate-400">{humanizeLabel(label)}</span>
|
|
341
|
+
<br />
|
|
342
|
+
{humanizeValue(label, value)}
|
|
343
|
+
</p>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function RelatedList({ label, values }: { label: string; values: string[] }) {
|
|
348
|
+
if (values.length === 0) {
|
|
349
|
+
return (
|
|
350
|
+
<p className="m-0 leading-tight">
|
|
351
|
+
<span className="text-slate-400">{label}</span>
|
|
352
|
+
<br />
|
|
353
|
+
<span className="text-slate-500">None</span>
|
|
354
|
+
</p>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div className="grid gap-1.5">
|
|
360
|
+
<p className="m-0 text-slate-400">{label}</p>
|
|
361
|
+
<div className="grid gap-1.5">
|
|
362
|
+
{values.map((value) => (
|
|
363
|
+
<div key={`${label}:${value}`} className="grid gap-1">
|
|
364
|
+
<p className="text-sm text-slate-200">{humanizeValue(label, value)}</p>
|
|
365
|
+
<Code>{value}</Code>
|
|
366
|
+
</div>
|
|
367
|
+
))}
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
374
|
+
return (
|
|
375
|
+
<section className="grid gap-2 rounded-xl border border-slate-700/80 bg-slate-900/40 p-3">
|
|
376
|
+
<p className="m-0 text-xs uppercase tracking-[0.08em] text-slate-400">{title}</p>
|
|
377
|
+
{children}
|
|
378
|
+
</section>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function MetadataDetails({
|
|
383
|
+
title,
|
|
384
|
+
defaultOpen,
|
|
385
|
+
children,
|
|
386
|
+
}: {
|
|
387
|
+
title: string;
|
|
388
|
+
defaultOpen?: boolean;
|
|
389
|
+
children: ReactNode;
|
|
390
|
+
}) {
|
|
391
|
+
return (
|
|
392
|
+
<details
|
|
393
|
+
className="rounded-lg border border-slate-700/70 bg-slate-900/30 p-2 [&_summary::-webkit-details-marker]:hidden"
|
|
394
|
+
open={defaultOpen}
|
|
395
|
+
>
|
|
396
|
+
<summary className="cursor-pointer list-none text-sm font-medium text-slate-200">{title}</summary>
|
|
397
|
+
<div className="mt-2 grid gap-2">{children}</div>
|
|
398
|
+
</details>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function MetadataValue({
|
|
403
|
+
label,
|
|
404
|
+
value,
|
|
405
|
+
}: {
|
|
406
|
+
label: string;
|
|
407
|
+
value: FrontmatterValue;
|
|
408
|
+
}) {
|
|
409
|
+
if (value === null) {
|
|
410
|
+
return (
|
|
411
|
+
<div className="grid gap-1">
|
|
412
|
+
<p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
|
|
413
|
+
<p className="m-0 text-sm text-slate-500">None</p>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (Array.isArray(value)) {
|
|
419
|
+
if (value.length === 0) {
|
|
420
|
+
return (
|
|
421
|
+
<div className="grid gap-1">
|
|
422
|
+
<p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
|
|
423
|
+
<p className="m-0 text-sm text-slate-500">None</p>
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const listLike = normalizeKey(label).includes("criteria");
|
|
429
|
+
return (
|
|
430
|
+
<div className="grid gap-1.5">
|
|
431
|
+
<p className="m-0 text-xs uppercase tracking-[0.06em] text-slate-400">{humanizeLabel(label)}</p>
|
|
432
|
+
{listLike ? (
|
|
433
|
+
<ol className="m-0 grid list-decimal gap-1 pl-5 text-sm text-slate-200">
|
|
434
|
+
{value.map((item) => (
|
|
435
|
+
<li key={`${label}:${item}`}>{humanizeValue(label, item)}</li>
|
|
436
|
+
))}
|
|
437
|
+
</ol>
|
|
438
|
+
) : (
|
|
439
|
+
<div className="flex flex-wrap gap-1.5">
|
|
440
|
+
{value.map((item) => (
|
|
441
|
+
<Badge key={`${label}:${item}`} variant="secondary">
|
|
442
|
+
{humanizeValue(label, item)}
|
|
443
|
+
</Badge>
|
|
444
|
+
))}
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return <Field label={label} value={formatDateValue(value)} />;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function InspectorPanel() {
|
|
455
|
+
const { selection, setSelection } = useInspector();
|
|
456
|
+
const { rightCollapsed, setRightCollapsed } = useWorkspace();
|
|
457
|
+
const [trace, setTrace] = useState<TracePayload | null>(null);
|
|
458
|
+
const [levelFiles, setLevelFiles] = useState<Array<{ path: string; content: string }>>([]);
|
|
459
|
+
const [selectedLevelFilePath, setSelectedLevelFilePath] = useState<string | null>(null);
|
|
460
|
+
const [entityDetails, setEntityDetails] = useState<EntityDetails | null>(null);
|
|
461
|
+
const [graphWarnings, setGraphWarnings] = useState<GraphValidationIssueData[]>([]);
|
|
462
|
+
|
|
463
|
+
const selectedLevelFile = selectedLevelFilePath
|
|
464
|
+
? (levelFiles.find((file) => file.path === selectedLevelFilePath) ?? null)
|
|
465
|
+
: null;
|
|
466
|
+
const selectedLevelFileExt = selectedLevelFile
|
|
467
|
+
? (selectedLevelFile.path.split(".").pop()?.toLowerCase() ?? "")
|
|
468
|
+
: "";
|
|
469
|
+
const selectedLevelFileFrontmatter = selectedLevelFile
|
|
470
|
+
? parseFrontmatter(selectedLevelFile.content)
|
|
471
|
+
: {};
|
|
472
|
+
const selectedLevelFileFrontmatterEntries = Object.entries(selectedLevelFileFrontmatter);
|
|
473
|
+
const [showEmptyFileMetadata, setShowEmptyFileMetadata] = useState(false);
|
|
474
|
+
|
|
475
|
+
const fileMetadataGroups = useMemo(() => {
|
|
476
|
+
const keyOrder = {
|
|
477
|
+
identity: ["schemaVersion", "id", "slug", "title"],
|
|
478
|
+
workflow: ["lane", "status", "createdAt", "updatedAt", "discoveredFromTask"],
|
|
479
|
+
references: ["tags", "codeTargets", "publicDocs", "decisions"],
|
|
480
|
+
criteria: ["completionCriteria"],
|
|
481
|
+
} as const;
|
|
482
|
+
|
|
483
|
+
const sourceEntries = new Map(selectedLevelFileFrontmatterEntries);
|
|
484
|
+
const pick = (keys: readonly string[]) =>
|
|
485
|
+
keys
|
|
486
|
+
.filter((key) => sourceEntries.has(key))
|
|
487
|
+
.map((key) => [key, sourceEntries.get(key) ?? null] as const)
|
|
488
|
+
.filter(([, value]) => showEmptyFileMetadata || !(value === null || (Array.isArray(value) && value.length === 0)));
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
identity: pick(keyOrder.identity),
|
|
492
|
+
workflow: pick(keyOrder.workflow),
|
|
493
|
+
references: pick(keyOrder.references),
|
|
494
|
+
criteria: pick(keyOrder.criteria),
|
|
495
|
+
summary: [
|
|
496
|
+
["id", sourceEntries.get("id") ?? null] as const,
|
|
497
|
+
["schemaVersion", sourceEntries.get("schemaVersion") ?? null] as const,
|
|
498
|
+
["createdAt", sourceEntries.get("createdAt") ?? null] as const,
|
|
499
|
+
["updatedAt", sourceEntries.get("updatedAt") ?? null] as const,
|
|
500
|
+
].filter(([, value]) => value !== null && !(Array.isArray(value) && value.length === 0)),
|
|
501
|
+
};
|
|
502
|
+
}, [selectedLevelFileFrontmatterEntries, showEmptyFileMetadata]);
|
|
503
|
+
|
|
504
|
+
const taskLikeType = selection?.type && isTaskLikeType(selection.type) ? selection.type : null;
|
|
505
|
+
const fileBackedType =
|
|
506
|
+
selection?.type && isFileBackedType(selection.type) ? selection.type : null;
|
|
507
|
+
const selectionId = selection?.id ?? null;
|
|
508
|
+
const isFileSelection = selection?.type === "file";
|
|
509
|
+
const hasFileDocument = Boolean(selectedLevelFile);
|
|
510
|
+
const fileLaneValue = selectedLevelFileFrontmatter.lane;
|
|
511
|
+
const fileStatusValue = selectedLevelFileFrontmatter.status;
|
|
512
|
+
const filteredSelectionMetadata = (selection?.metadata ?? []).filter((item) => {
|
|
513
|
+
if (!isFileSelection) return true;
|
|
514
|
+
if (!selectedLevelFile?.path) return true;
|
|
515
|
+
return normalizeKey(item.label) !== "path";
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const rawTaskMetadata = useMemo(() => {
|
|
519
|
+
if (selection?.type !== "task") return [];
|
|
520
|
+
const combined = [...(selection.metadata ?? []), ...(entityDetails?.summary ?? [])];
|
|
521
|
+
const map = createMetadataMap(combined);
|
|
522
|
+
|
|
523
|
+
const id = selection.id ?? getMetadataValue(map, "Task ID", "ID") ?? "n/a";
|
|
524
|
+
const parsed = parseTaskId(id);
|
|
525
|
+
const phase = getMetadataValue(map, "Phase") ?? parsed.phaseId ?? "n/a";
|
|
526
|
+
const milestone = getMetadataValue(map, "Milestone") ?? parsed.milestoneId ?? "n/a";
|
|
527
|
+
const taskNumber = parsed.taskNumber ?? "n/a";
|
|
528
|
+
const title = getMetadataValue(map, "Title") ?? selection.title ?? "n/a";
|
|
529
|
+
const lane = getMetadataValue(map, "Lane") ?? "n/a";
|
|
530
|
+
const status = getMetadataValue(map, "Status") ?? "n/a";
|
|
531
|
+
const domain = getMetadataValue(map, "Domain") ?? "unassigned";
|
|
532
|
+
const graphNode = getMetadataValue(map, "Graph Node");
|
|
533
|
+
|
|
534
|
+
const ordered: MetadataRecord[] = [
|
|
535
|
+
{ label: "Phase", value: phase },
|
|
536
|
+
{ label: "Milestone", value: milestone },
|
|
537
|
+
{ label: "Task Number", value: taskNumber },
|
|
538
|
+
{ label: "Task ID", value: id },
|
|
539
|
+
{ label: "Title", value: title },
|
|
540
|
+
{ label: "Lane", value: lane },
|
|
541
|
+
{ label: "Status", value: status },
|
|
542
|
+
{ label: "Domain", value: domain },
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
if (graphNode) {
|
|
546
|
+
ordered.push({ label: "Graph Node", value: graphNode });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return ordered;
|
|
550
|
+
}, [entityDetails?.summary, selection]);
|
|
551
|
+
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!selection?.id || selection.type !== "task") {
|
|
554
|
+
setTrace(null);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
void getTaskTrace(selection.id)
|
|
559
|
+
.then((traceResult) => {
|
|
560
|
+
setTrace(traceResult as TracePayload);
|
|
561
|
+
})
|
|
562
|
+
.catch(() => {
|
|
563
|
+
setTrace(null);
|
|
564
|
+
});
|
|
565
|
+
}, [selection]);
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
setEntityDetails(null);
|
|
569
|
+
|
|
570
|
+
if (!selectionId || !taskLikeType) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
void getArchitectureMap()
|
|
575
|
+
.then((map) => {
|
|
576
|
+
setEntityDetails(deriveEntityDetails(map, taskLikeType, selectionId));
|
|
577
|
+
})
|
|
578
|
+
.catch(() => {
|
|
579
|
+
setEntityDetails(null);
|
|
580
|
+
});
|
|
581
|
+
}, [selectionId, taskLikeType]);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
setLevelFiles([]);
|
|
585
|
+
setSelectedLevelFilePath(null);
|
|
586
|
+
|
|
587
|
+
if (!selectionId || !fileBackedType) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
void getNodeFiles(fileBackedType, selectionId)
|
|
592
|
+
.then((result) => {
|
|
593
|
+
setLevelFiles(result.files);
|
|
594
|
+
const preferred =
|
|
595
|
+
result.files.find((file) => file.path.endsWith(".md"))?.path ??
|
|
596
|
+
result.files[0]?.path ??
|
|
597
|
+
null;
|
|
598
|
+
setSelectedLevelFilePath(preferred);
|
|
599
|
+
})
|
|
600
|
+
.catch(() => {
|
|
601
|
+
setLevelFiles([]);
|
|
602
|
+
setSelectedLevelFilePath(null);
|
|
603
|
+
});
|
|
604
|
+
}, [selectionId, fileBackedType]);
|
|
605
|
+
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
void getGraphDataset()
|
|
608
|
+
.then((result) => setGraphWarnings(result.validation.warnings))
|
|
609
|
+
.catch(() => setGraphWarnings([]));
|
|
610
|
+
}, []);
|
|
611
|
+
|
|
612
|
+
if (rightCollapsed) {
|
|
613
|
+
return (
|
|
614
|
+
<aside className="grid h-full min-h-0 content-start justify-items-center overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-2">
|
|
615
|
+
<Button
|
|
616
|
+
variant="outline"
|
|
617
|
+
type="button"
|
|
618
|
+
onClick={() => setRightCollapsed(false)}
|
|
619
|
+
title="Expand inspector (Ctrl/Cmd+I)"
|
|
620
|
+
>
|
|
621
|
+
Inspector
|
|
622
|
+
</Button>
|
|
623
|
+
</aside>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!selection) {
|
|
628
|
+
return (
|
|
629
|
+
<aside className="h-full min-h-0 overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-4">
|
|
630
|
+
<div className="grid gap-2 rounded-xl border border-slate-700 bg-slate-900/90 p-3">
|
|
631
|
+
<h3 className="m-0 text-base font-semibold">Inspector</h3>
|
|
632
|
+
<p className="text-sm text-slate-400">
|
|
633
|
+
Select a graph node or task row to inspect metadata and traceability.
|
|
634
|
+
</p>
|
|
635
|
+
{graphWarnings.length > 0 ? (
|
|
636
|
+
<div className="mt-2 rounded-lg border border-amber-700/70 bg-amber-950/40 p-2 text-xs text-amber-200">
|
|
637
|
+
<p className="m-0 font-medium">Graph Diagnostics ({graphWarnings.length})</p>
|
|
638
|
+
{graphWarnings.slice(0, 4).map((issue, index) => (
|
|
639
|
+
<p key={`${issue.ruleId}:${index}`} className="m-0">
|
|
640
|
+
[{issue.ruleId}] {issue.message}
|
|
641
|
+
</p>
|
|
642
|
+
))}
|
|
643
|
+
</div>
|
|
644
|
+
) : null}
|
|
645
|
+
</div>
|
|
646
|
+
</aside>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<aside className="h-full min-h-0 overflow-y-auto overflow-x-visible border-l border-slate-800 bg-slate-950/85 p-4">
|
|
652
|
+
<div className="grid gap-3">
|
|
653
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
654
|
+
<Badge variant="secondary">{humanizeLabel(hasFileDocument ? "file" : selection.type)}</Badge>
|
|
655
|
+
{typeof fileLaneValue === "string" ? <Badge variant="secondary">{humanizeValue("lane", fileLaneValue)}</Badge> : null}
|
|
656
|
+
{typeof fileStatusValue === "string" ? <Badge variant="secondary">{humanizeValue("status", fileStatusValue)}</Badge> : null}
|
|
657
|
+
{selectedLevelFile?.path ? (
|
|
658
|
+
<Button
|
|
659
|
+
variant="outline"
|
|
660
|
+
type="button"
|
|
661
|
+
onClick={() => {
|
|
662
|
+
void navigator.clipboard?.writeText(selectedLevelFile.path);
|
|
663
|
+
}}
|
|
664
|
+
title="Copy this path, then use Ctrl+P in your editor and paste it."
|
|
665
|
+
>
|
|
666
|
+
Copy Path (Ctrl+P)
|
|
667
|
+
</Button>
|
|
668
|
+
) : null}
|
|
669
|
+
{selection.type === "task" && !hasFileDocument ? (
|
|
670
|
+
<Button variant="ghost" type="button" onClick={() => setSelection(null)}>
|
|
671
|
+
Close Task
|
|
672
|
+
</Button>
|
|
673
|
+
) : null}
|
|
674
|
+
{hasFileDocument ? (
|
|
675
|
+
<Button
|
|
676
|
+
variant="ghost"
|
|
677
|
+
type="button"
|
|
678
|
+
onClick={() => setShowEmptyFileMetadata((prev) => !prev)}
|
|
679
|
+
>
|
|
680
|
+
{showEmptyFileMetadata ? "Hide Empty Fields" : "Show Empty Fields"}
|
|
681
|
+
</Button>
|
|
682
|
+
) : null}
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<h3 className="m-0 text-base font-semibold">{selection.title}</h3>
|
|
686
|
+
|
|
687
|
+
{graphWarnings.length > 0 ? (
|
|
688
|
+
<Section title="Graph Diagnostics">
|
|
689
|
+
{graphWarnings.slice(0, 8).map((issue, index) => (
|
|
690
|
+
<Code key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</Code>
|
|
691
|
+
))}
|
|
692
|
+
</Section>
|
|
693
|
+
) : null}
|
|
694
|
+
|
|
695
|
+
{selection.id && !hasFileDocument ? (
|
|
696
|
+
<p className="m-0 leading-tight">
|
|
697
|
+
<span className="text-slate-400">ID</span>
|
|
698
|
+
<br />
|
|
699
|
+
<Code>{selection.id}</Code>
|
|
700
|
+
</p>
|
|
701
|
+
) : null}
|
|
702
|
+
|
|
703
|
+
<Separator />
|
|
704
|
+
|
|
705
|
+
{selectedLevelFile ? (
|
|
706
|
+
<>
|
|
707
|
+
<div className="grid gap-3">
|
|
708
|
+
{levelFiles.length > 1 ? (
|
|
709
|
+
<div className="flex flex-wrap gap-1.5 rounded-lg border border-slate-700/80 bg-slate-900/40 p-2">
|
|
710
|
+
{levelFiles.map((file) => (
|
|
711
|
+
<Button
|
|
712
|
+
key={file.path}
|
|
713
|
+
variant={file.path === selectedLevelFilePath ? "secondary" : "ghost"}
|
|
714
|
+
type="button"
|
|
715
|
+
onClick={() => setSelectedLevelFilePath(file.path)}
|
|
716
|
+
title={file.path}
|
|
717
|
+
>
|
|
718
|
+
{file.path.split("/").pop()}
|
|
719
|
+
</Button>
|
|
720
|
+
))}
|
|
721
|
+
</div>
|
|
722
|
+
) : null}
|
|
723
|
+
<Section title="Document">
|
|
724
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
725
|
+
<Button
|
|
726
|
+
variant="outline"
|
|
727
|
+
type="button"
|
|
728
|
+
onClick={() => {
|
|
729
|
+
void navigator.clipboard?.writeText(selectedLevelFile.path);
|
|
730
|
+
}}
|
|
731
|
+
title="Copy this path, then use Ctrl+P in your editor and paste it."
|
|
732
|
+
>
|
|
733
|
+
Copy Path (Ctrl+P)
|
|
734
|
+
</Button>
|
|
735
|
+
</div>
|
|
736
|
+
<Field label="Source" value={selectedLevelFile.path} />
|
|
737
|
+
</Section>
|
|
738
|
+
|
|
739
|
+
{selectedLevelFileFrontmatterEntries.length > 0 ? (
|
|
740
|
+
<Section title="Metadata Summary">
|
|
741
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
742
|
+
{fileMetadataGroups.summary.map(([label, value]) => (
|
|
743
|
+
<MetadataValue key={`file-summary:${label}`} label={label} value={value} />
|
|
744
|
+
))}
|
|
745
|
+
</div>
|
|
746
|
+
</Section>
|
|
747
|
+
) : null}
|
|
748
|
+
|
|
749
|
+
{selectedLevelFileFrontmatterEntries.length > 0 ? (
|
|
750
|
+
<Section title="File Metadata">
|
|
751
|
+
{fileMetadataGroups.identity.length > 0 ? (
|
|
752
|
+
<MetadataDetails title="Identity">
|
|
753
|
+
{fileMetadataGroups.identity.map(([label, value]) => (
|
|
754
|
+
<MetadataValue key={`file-identity:${label}`} label={label} value={value} />
|
|
755
|
+
))}
|
|
756
|
+
</MetadataDetails>
|
|
757
|
+
) : null}
|
|
758
|
+
|
|
759
|
+
{fileMetadataGroups.workflow.length > 0 ? (
|
|
760
|
+
<MetadataDetails title="Workflow" defaultOpen>
|
|
761
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-2">
|
|
762
|
+
{fileMetadataGroups.workflow.map(([label, value]) => (
|
|
763
|
+
<div
|
|
764
|
+
key={`file-workflow:${label}`}
|
|
765
|
+
className="rounded-lg border border-slate-700/70 bg-slate-950/40 p-2"
|
|
766
|
+
>
|
|
767
|
+
<MetadataValue label={label} value={value} />
|
|
768
|
+
</div>
|
|
769
|
+
))}
|
|
770
|
+
</div>
|
|
771
|
+
</MetadataDetails>
|
|
772
|
+
) : null}
|
|
773
|
+
|
|
774
|
+
{fileMetadataGroups.references.length > 0 ? (
|
|
775
|
+
<MetadataDetails title="References">
|
|
776
|
+
{fileMetadataGroups.references.map(([label, value]) => (
|
|
777
|
+
<MetadataValue key={`file-references:${label}`} label={label} value={value} />
|
|
778
|
+
))}
|
|
779
|
+
</MetadataDetails>
|
|
780
|
+
) : null}
|
|
781
|
+
|
|
782
|
+
{fileMetadataGroups.criteria.length > 0 ? (
|
|
783
|
+
<MetadataDetails title="Completion Criteria">
|
|
784
|
+
{fileMetadataGroups.criteria.map(([label, value]) => (
|
|
785
|
+
<MetadataValue key={`file-criteria:${label}`} label={label} value={value} />
|
|
786
|
+
))}
|
|
787
|
+
</MetadataDetails>
|
|
788
|
+
) : null}
|
|
789
|
+
</Section>
|
|
790
|
+
) : null}
|
|
791
|
+
|
|
792
|
+
<Section title="Content">
|
|
793
|
+
{selectedLevelFileExt === "md" ? (
|
|
794
|
+
<MarkdownViewer markdown={stripFrontmatter(selectedLevelFile.content)} />
|
|
795
|
+
) : (
|
|
796
|
+
<pre className="overflow-x-auto rounded-xl border border-slate-700 bg-slate-950 p-3 text-xs text-slate-200">
|
|
797
|
+
{selectedLevelFile.content}
|
|
798
|
+
</pre>
|
|
799
|
+
)}
|
|
800
|
+
</Section>
|
|
801
|
+
</div>
|
|
802
|
+
</>
|
|
803
|
+
) : selection.type === "task" ? (
|
|
804
|
+
<>
|
|
805
|
+
{rawTaskMetadata.length > 0 ? (
|
|
806
|
+
<div className="grid gap-1.5">
|
|
807
|
+
<p className="text-slate-400">Task Metadata</p>
|
|
808
|
+
{rawTaskMetadata.map((item) => (
|
|
809
|
+
<Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
|
|
810
|
+
))}
|
|
811
|
+
</div>
|
|
812
|
+
) : null}
|
|
813
|
+
|
|
814
|
+
{entityDetails ? (
|
|
815
|
+
<>
|
|
816
|
+
<Separator />
|
|
817
|
+
<div className="grid gap-2">
|
|
818
|
+
<p className="text-slate-400">Related Data</p>
|
|
819
|
+
<RelatedList label="Tasks" values={entityDetails.relatedTasks} />
|
|
820
|
+
<RelatedList label="Milestones" values={entityDetails.relatedMilestones} />
|
|
821
|
+
<RelatedList label="Decisions" values={entityDetails.relatedDecisions} />
|
|
822
|
+
<RelatedList label="Modules" values={entityDetails.relatedModules} />
|
|
823
|
+
</div>
|
|
824
|
+
</>
|
|
825
|
+
) : null}
|
|
826
|
+
|
|
827
|
+
<Separator />
|
|
828
|
+
<div className="grid gap-1.5">
|
|
829
|
+
<p className="text-slate-400">Task Trace</p>
|
|
830
|
+
{trace?.decisionRefs?.map((decision) => (
|
|
831
|
+
<Code key={decision}>{decision}</Code>
|
|
832
|
+
))}
|
|
833
|
+
{trace?.moduleRefs?.map((moduleRef) => (
|
|
834
|
+
<Code key={moduleRef}>{moduleRef}</Code>
|
|
835
|
+
))}
|
|
836
|
+
{trace?.files?.map((file) => (
|
|
837
|
+
<Code key={file}>{file}</Code>
|
|
838
|
+
))}
|
|
839
|
+
{!trace?.decisionRefs?.length && !trace?.moduleRefs?.length && !trace?.files?.length ? (
|
|
840
|
+
<p className="text-slate-400">No trace links found.</p>
|
|
841
|
+
) : null}
|
|
842
|
+
</div>
|
|
843
|
+
<p className="text-slate-400">Task document not available.</p>
|
|
844
|
+
</>
|
|
845
|
+
) : (
|
|
846
|
+
<>
|
|
847
|
+
{filteredSelectionMetadata.map((item) => (
|
|
848
|
+
<Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
|
|
849
|
+
))}
|
|
850
|
+
|
|
851
|
+
{entityDetails ? (
|
|
852
|
+
<>
|
|
853
|
+
<Separator />
|
|
854
|
+
<div className="grid gap-2">
|
|
855
|
+
<p className="text-slate-400">Entity Data</p>
|
|
856
|
+
{entityDetails.summary.map((item) => (
|
|
857
|
+
<Field key={`${item.label}:${item.value}`} label={item.label} value={item.value} />
|
|
858
|
+
))}
|
|
859
|
+
<RelatedList label="Tasks" values={entityDetails.relatedTasks} />
|
|
860
|
+
<RelatedList label="Milestones" values={entityDetails.relatedMilestones} />
|
|
861
|
+
<RelatedList label="Decisions" values={entityDetails.relatedDecisions} />
|
|
862
|
+
<RelatedList label="Modules" values={entityDetails.relatedModules} />
|
|
863
|
+
</div>
|
|
864
|
+
</>
|
|
865
|
+
) : null}
|
|
866
|
+
|
|
867
|
+
{selection.links && selection.links.length > 0 ? (
|
|
868
|
+
<>
|
|
869
|
+
<Separator />
|
|
870
|
+
<div>
|
|
871
|
+
<p className="text-slate-400">Trace Links</p>
|
|
872
|
+
<div className="grid gap-1.5">
|
|
873
|
+
{selection.links.map((link) => (
|
|
874
|
+
<Code key={link}>{link}</Code>
|
|
875
|
+
))}
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
</>
|
|
879
|
+
) : null}
|
|
880
|
+
|
|
881
|
+
{selection.markdown ? (
|
|
882
|
+
<>
|
|
883
|
+
<Separator />
|
|
884
|
+
<div className="grid gap-1.5">
|
|
885
|
+
<p className="text-slate-400">Node Notes</p>
|
|
886
|
+
<MarkdownViewer markdown={selection.markdown} />
|
|
887
|
+
</div>
|
|
888
|
+
</>
|
|
889
|
+
) : null}
|
|
890
|
+
</>
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
</aside>
|
|
894
|
+
);
|
|
895
|
+
}
|