diff --git a/dashboard/app/context/page.tsx b/dashboard/app/context/page.tsx index 5e2af5d5..049ed641 100644 --- a/dashboard/app/context/page.tsx +++ b/dashboard/app/context/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { api } from "@/lib/api"; -import { formatRecordKind, formatScopeLabel } from "@/lib/labels"; +import { formatRecordKind, formatRecordRole, formatScopeLabel } from "@/lib/labels"; import type { ContextRecord, IntelligenceResponse } from "@/lib/types"; import RecordEditor from "@/components/RecordEditor"; @@ -12,10 +12,12 @@ export default function ContextPage() { const [debouncedQuery, setDebouncedQuery] = useState(""); const [project, setProject] = useState(""); const [selectedType, setSelectedType] = useState(""); + const [selectedRole, setSelectedRole] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); /* ---- filter options from server ---- */ const [filterTypes, setFilterTypes] = useState([]); + const [filterRoles, setFilterRoles] = useState([]); const [filterProjects, setFilterProjects] = useState([]); /* ---- data state ---- */ @@ -53,6 +55,7 @@ export default function ContextPage() { .getRecordFilters() .then((f) => { setFilterTypes(f.types); + setFilterRoles(f.roles); setFilterProjects(f.projects); }) .catch(() => { @@ -69,6 +72,7 @@ export default function ContextPage() { if (debouncedQuery) params.q = debouncedQuery; if (project) params.project = project; if (selectedType) params.record_kind = selectedType; + if (selectedRole) params.record_role = selectedRole; if (statusFilter) params.status = statusFilter; const data = await api.getRecords(params); setRecords(data.records); @@ -78,7 +82,7 @@ export default function ContextPage() { } finally { setLoading(false); } - }, [debouncedQuery, project, selectedType, statusFilter]); + }, [debouncedQuery, project, selectedRole, selectedType, statusFilter]); useEffect(() => { load(); @@ -100,6 +104,8 @@ export default function ContextPage() { setSelected(record); }; + const operationalFilterRoles = filterRoles.filter((role) => role !== "general"); + const scrollToIntel = () => { setIntelCollapsed(false); setTimeout(() => { @@ -199,6 +205,34 @@ export default function ContextPage() { ))} + {operationalFilterRoles.length > 0 && ( +
+ + {operationalFilterRoles.map((role) => ( + + ))} +
+ )} + {/* ---- Error ---- */} {error && (
@@ -246,6 +280,11 @@ export default function ContextPage() { {formatRecordKind(record.record_kind)} + {record.record_role && record.record_role !== "general" && ( + + {formatRecordRole(record.record_role)} + + )} {record.project && ( {formatScopeLabel(record.project)} diff --git a/dashboard/app/skills/page.tsx b/dashboard/app/skills/page.tsx new file mode 100644 index 00000000..6d5ce6b4 --- /dev/null +++ b/dashboard/app/skills/page.tsx @@ -0,0 +1,994 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { api } from "@/lib/api"; +import type { SkillProposal, SkillTarget } from "@/lib/types"; +import { useToast } from "@/components/Toast"; + +type ConfirmAction = + | { kind: "apply"; proposal: SkillProposal } + | { kind: "reject"; proposal: SkillProposal } + | { kind: "auto_apply"; target: SkillTarget; enabled: boolean }; + +export default function SkillsPage() { + const { addToast } = useToast(); + const [targets, setTargets] = useState([]); + const [proposals, setProposals] = useState([]); + const [selectedTargetId, setSelectedTargetId] = useState(""); + const [selectedProposalId, setSelectedProposalId] = useState(""); + const [path, setPath] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [editText, setEditText] = useState(""); + const [selectedPatchIndex, setSelectedPatchIndex] = useState(0); + const [confirmAction, setConfirmAction] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const [targetData, proposalData] = await Promise.all([api.getSkillTargets(), api.getSkillProposals()]); + setTargets(targetData.targets); + setProposals(proposalData.proposals); + setSelectedTargetId((current) => + current && targetData.targets.some((target) => target.target_id === current) + ? current + : targetData.targets[0]?.target_id || "", + ); + setSelectedProposalId((current) => + current && proposalData.proposals.some((proposal) => proposal.proposal_id === current) + ? current + : proposalData.proposals[0]?.proposal_id || "", + ); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Failed to load skills" }); + } finally { + setLoading(false); + } + }, [addToast]); + + useEffect(() => { + load(); + }, [load]); + + const selectedTarget = targets.find((target) => target.target_id === selectedTargetId) || targets[0] || null; + const filteredProposals = useMemo( + () => proposals.filter((proposal) => !selectedTarget || proposal.target_id === selectedTarget.target_id), + [proposals, selectedTarget], + ); + const selectedProposal = + filteredProposals.find((proposal) => proposal.proposal_id === selectedProposalId) || filteredProposals[0] || null; + const selectedPatch = selectedProposal?.patch_json.patches[selectedPatchIndex] || null; + const hasUnsavedEdit = Boolean(selectedPatch && editText !== (selectedPatch.after_text || "")); + + useEffect(() => { + setSelectedPatchIndex(0); + }, [selectedProposal?.proposal_id]); + + useEffect(() => { + setEditText(selectedPatch?.after_text || ""); + }, [selectedPatch?.after_text, selectedPatchIndex, selectedProposal?.proposal_id]); + + const canDiscardEdits = useCallback(() => { + return !hasUnsavedEdit || window.confirm("Discard unsaved edits?"); + }, [hasUnsavedEdit]); + + const selectTarget = (targetId: string) => { + if (targetId === selectedTargetId || !canDiscardEdits()) return; + setSelectedTargetId(targetId); + setSelectedProposalId(proposals.find((proposal) => proposal.target_id === targetId)?.proposal_id || ""); + setSelectedPatchIndex(0); + }; + + const selectProposal = (proposalId: string) => { + if (proposalId === selectedProposalId || !canDiscardEdits()) return; + setSelectedProposalId(proposalId); + setSelectedPatchIndex(0); + }; + + const selectPatch = (index: number) => { + if (index === selectedPatchIndex || !canDiscardEdits()) return; + setSelectedPatchIndex(index); + }; + + const register = async () => { + if (!path.trim()) return; + setBusy("register"); + try { + const result = await api.addSkillTarget({ + path: path.trim(), + name: name.trim() || undefined, + description: description.trim() || undefined, + }); + setPath(""); + setName(""); + setDescription(""); + addToast({ type: "success", message: "Skill registered" }); + await load(); + setSelectedTargetId(result.target.target_id); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Could not register skill" }); + } finally { + setBusy(null); + } + }; + + const refreshTarget = async (target: SkillTarget) => { + if (!canDiscardEdits()) return; + setBusy(`refresh:${target.target_id}`); + try { + await api.refreshSkillTarget(target.target_id); + addToast({ type: "success", message: "Scan completed" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Scan failed" }); + } finally { + setBusy(null); + } + }; + + const updateAutoApply = async (target: SkillTarget, enabled: boolean) => { + setBusy(`mode:${target.target_id}`); + try { + await api.updateSkillTargetMode(target.target_id, { + update_mode: enabled ? "auto_apply" : "review", + auto_apply_policy: { ...target.auto_apply_policy, enabled }, + }); + addToast({ type: "success", message: enabled ? "Auto-apply enabled" : "Auto-apply disabled" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Mode update failed" }); + } finally { + setBusy(null); + } + }; + + const toggleAutoApply = (target: SkillTarget) => { + const enabled = target.update_mode !== "auto_apply"; + if (enabled) { + setConfirmAction({ kind: "auto_apply", target, enabled }); + return; + } + updateAutoApply(target, false); + }; + + const updatePolicyRisk = async (target: SkillTarget, maxRisk: string) => { + setBusy(`mode:${target.target_id}`); + try { + await api.updateSkillTargetMode(target.target_id, { + update_mode: target.update_mode, + auto_apply_policy: { ...target.auto_apply_policy, max_risk: maxRisk }, + }); + addToast({ type: "success", message: "Policy updated" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Policy update failed" }); + } finally { + setBusy(null); + } + }; + + const applyProposal = async (proposal: SkillProposal) => { + setBusy(`apply:${proposal.proposal_id}`); + try { + await api.applySkillProposal(proposal.proposal_id); + addToast({ type: "success", message: "Proposal applied" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Apply failed" }); + } finally { + setBusy(null); + } + }; + + const rejectProposal = async (proposal: SkillProposal) => { + setBusy(`reject:${proposal.proposal_id}`); + try { + await api.rejectSkillProposal(proposal.proposal_id); + addToast({ type: "success", message: "Proposal rejected" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Reject failed" }); + } finally { + setBusy(null); + } + }; + + const saveEditedProposal = async (proposal: SkillProposal) => { + if (!selectedPatch) return; + setBusy(`edit:${proposal.proposal_id}`); + try { + await api.editSkillProposal(proposal.proposal_id, { + ...proposal.patch_json, + patches: proposal.patch_json.patches.map((patch, index) => + index === selectedPatchIndex ? { ...patch, after_text: editText } : patch, + ), + }); + addToast({ type: "success", message: "Proposal updated" }); + await load(); + } catch (err) { + addToast({ type: "error", message: err instanceof Error ? err.message : "Edit failed" }); + } finally { + setBusy(null); + } + }; + + const confirmPendingAction = async () => { + const action = confirmAction; + setConfirmAction(null); + if (!action) return; + if (action.kind === "apply") await applyProposal(action.proposal); + if (action.kind === "reject") await rejectProposal(action.proposal); + if (action.kind === "auto_apply") await updateAutoApply(action.target, action.enabled); + }; + + return ( +
+
+
+

Skills

+

+ Registered instruction artifacts, evidence-backed proposals, and review gates. +

+
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+

Registered Skills

+
+
+ {loading &&
Loading...
} + {!loading && targets.length === 0 && ( +
Register a path above to start.
+ )} + {targets.map((target) => ( + + ))} +
+
+ +
+ {selectedTarget ? ( +
+
+
+

{selectedTarget.name}

+

{selectedTarget.path}

+
+
+ + +
+
+
+ + + + proposal.status === "pending_review").length)} + /> +
+
+ + + updatePolicyRisk(selectedTarget, risk)} + /> +
+
+ ) : ( +
+ Register a skill or instruction file to review updates. +
+ )} + +
+ + setConfirmAction({ kind: "apply", proposal })} + onReject={(proposal) => setConfirmAction({ kind: "reject", proposal })} + busy={busy} + /> +
+
+
+ + {confirmAction && ( + setConfirmAction(null)} + onConfirm={confirmPendingAction} + busy={Boolean(busy)} + /> + )} +
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function FileList({ title, files }: { title: string; files: NonNullable }) { + return ( +
+

{title}

+
+ {files.length === 0 &&
No tracked files.
} + {files.map((file) => ( +
+ {file.relative_path} + + {formatLabel(file.file_role)} + +
+ ))} +
+
+ ); +} + +function ManifestPanel({ target }: { target: SkillTarget }) { + const manifest = target.manifest; + const surfaces = [...(manifest?.allowed_update_surfaces || []), ...(manifest?.high_risk_surfaces || [])]; + return ( +
+

Update Surfaces

+
+ {surfaces.length === 0 &&
No surfaces detected.
} + {(manifest?.allowed_update_surfaces || []).map((surface) => ( + + {formatLabel(surface)} + + ))} + {(manifest?.high_risk_surfaces || []).map((surface) => ( + + {formatLabel(surface)} + + ))} +
+
+ ); +} + +function PolicyPanel({ + target, + busy, + onRiskChange, +}: { + target: SkillTarget; + busy: boolean; + onRiskChange: (risk: string) => void; +}) { + const policy = target.auto_apply_policy; + return ( +
+
+

Auto-apply Policy

+ {policy.enabled ? "Enabled" : "Disabled"} +
+ +
+ + + +
+
+ {policy.allow_entry_file_body && } + {policy.allow_new_reference_files && } + {policy.allow_scripts && } + {policy.allow_assets && } + {policy.allow_frontmatter && } +
+
+ ); +} + +function PolicyMetric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PolicyChip({ label }: { label: string }) { + return {label}; +} + +function ProposalList({ + proposals, + selectedId, + hasTarget, + onSelect, +}: { + proposals: SkillProposal[]; + selectedId: string; + hasTarget: boolean; + onSelect: (id: string) => void; +}) { + return ( +
+
+

Update Proposals

+
+
+ {proposals.length === 0 && ( +
+ {hasTarget ? "No proposals yet. Scan this skill to draft updates." : "Register a skill first."} +
+ )} + {proposals.map((proposal) => ( + + ))} +
+
+ ); +} + +function ProposalReview({ + proposal, + editText, + onEditText, + selectedPatchIndex, + onSelectPatch, + onSave, + onApply, + onReject, + busy, +}: { + proposal: SkillProposal | null; + editText: string; + onEditText: (value: string) => void; + selectedPatchIndex: number; + onSelectPatch: (index: number) => void; + onSave: (proposal: SkillProposal) => void; + onApply: (proposal: SkillProposal) => void; + onReject: (proposal: SkillProposal) => void; + busy: string | null; +}) { + if (!proposal) { + return ( +
+ Select a proposal to review. +
+ ); + } + const patches = proposal.patch_json.patches; + const patch = patches[selectedPatchIndex] || patches[0]; + const hasUnsavedEdit = Boolean(patch && editText !== (patch.after_text || "")); + const canEdit = proposal.status === "pending_review" || proposal.status === "failed_validation"; + const canReject = !["applied", "rejected", "superseded"].includes(proposal.status); + const applyBlocker = applyBlockerText(proposal, hasUnsavedEdit); + const canApply = !applyBlocker; + const diffText = + patch && hasUnsavedEdit + ? previewDiff(patch.relative_path, patch.before_text || "", editText) + : patch?.diff_text || "No diff available until the proposal is regenerated."; + + return ( +
+
+
+
+

{proposal.title}

+

{proposal.summary}

+
+ + {formatLabel(proposal.risk_level)} + +
+
+ + {formatStatus(proposal.status)} + + {proposal.status === "pending_review" && ( + + Not applied + + )} + {Array.from(new Set(patches.flatMap((item) => item.evidence_record_ids || []))).map((recordId) => ( + + {recordId} + + ))} +
+ {patches.length > 1 && ( +
+ {patches.map((item, index) => ( + + ))} +
+ )} +
+ {patch ? ( +
+
+
+ + {patch.relative_path} +
+ +
+
+
+

Change Diff

+ {hasUnsavedEdit && Unsaved edit} +
+ +
+
+ ) : ( +
This proposal has no patch.
+ )} +
+ +
+ + + +
+
+
+ ); +} + +function ConfirmDialog({ + action, + onCancel, + onConfirm, + busy, +}: { + action: ConfirmAction; + onCancel: () => void; + onConfirm: () => void; + busy: boolean; +}) { + const title = + action.kind === "apply" ? "Apply proposal?" : action.kind === "reject" ? "Reject proposal?" : "Enable auto-apply?"; + const body = confirmBody(action); + return ( +
+
+

+ {title} +

+
+ {body.map((line) => ( +

{line}

+ ))} +
+
+ + +
+
+
+ ); +} + +function confirmBody(action: ConfirmAction) { + if (action.kind === "auto_apply") { + const policy = action.target.auto_apply_policy; + return [ + `${action.target.name} will auto-apply proposals only when validation and guard checks pass.`, + `Policy: max risk ${formatLabel(policy.max_risk)}, ${policy.max_changed_files} files, ${policy.max_added_lines} added lines, ${policy.max_removed_lines ?? 20} removed lines.`, + ]; + } + const proposal = action.proposal; + const fileCount = proposal.patch_json.patches.length; + if (action.kind === "reject") { + return [`${proposal.title} will be marked rejected.`, `${fileCount} proposed file change${fileCount === 1 ? "" : "s"} will stay unapplied.`]; + } + return [ + `${proposal.title} will write ${fileCount} file change${fileCount === 1 ? "" : "s"} to disk.`, + `Validation: ${proposal.validation_json?.ok ? "passed" : "not passed"}. Guard: ${proposal.guard_json?.accepted ? "accepted" : "not accepted"}.`, + ]; +} + +function previewDiff(relativePath: string, before: string, after: string) { + const beforeLines = splitPreviewLines(before); + const afterLines = splitPreviewLines(after); + return [ + `--- a/${relativePath}`, + `+++ b/${relativePath}`, + "@@ unsaved edit preview @@", + ...beforeLines.map((line) => `-${line}`), + ...afterLines.map((line) => `+${line}`), + ].join("\n"); +} + +function splitPreviewLines(text: string) { + const trimmed = text.endsWith("\n") ? text.slice(0, -1) : text; + return trimmed ? trimmed.split("\n") : [""]; +} + +function FullFilePreview({ + id, + value, + onChange, + readOnly, +}: { + id: string; + value: string; + onChange: (value: string) => void; + readOnly: boolean; +}) { + const lineNumberRef = useRef(null); + const lineNumbers = useMemo( + () => Array.from({ length: Math.max(1, value.split("\n").length) }, (_line, index) => index + 1).join("\n"), + [value], + ); + const syncLineScroll = (event: UIEvent) => { + if (lineNumberRef.current) lineNumberRef.current.scrollTop = event.currentTarget.scrollTop; + }; + + return ( +
+ +