579 lines
22 KiB
React
579 lines
22 KiB
React
"use client";
|
|
|
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
|
import DiagnosticsView from "@/components/diagnostics-view";
|
|
import GraphUpdateView from "@/components/graph-update-view";
|
|
import SituationGraphView from "@/components/situation-graph-view";
|
|
|
|
// ── Technical summary detector (main view filters these) ───
|
|
const TECHNICAL_PATTERNS = [
|
|
/nodes?\s*[:\d]/i,
|
|
/edges?\s*[:\d]/i,
|
|
/\b(?:unknown|observation|conclusion)\b\s/i,
|
|
/\bsorted\b/i,
|
|
/by_kind/i,
|
|
/\b(?:node|edge|unknown|state)\s+count/i,
|
|
];
|
|
|
|
function isTechnicalSummary(summary) {
|
|
if (!summary || typeof summary !== "string") return false;
|
|
const trimmed = summary.trim();
|
|
if (!trimmed) return false;
|
|
for (const p of TECHNICAL_PATTERNS) {
|
|
if (p.test(trimmed)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveCurrentSummary(currentSummary) {
|
|
if (isTechnicalSummary(currentSummary)) {
|
|
return null;
|
|
}
|
|
return currentSummary || null;
|
|
}
|
|
|
|
// ── Status message pools for loading feedback ────────────────
|
|
const INITIAL_MESSAGES = [
|
|
{ min: 0, text: "Reading your situation" },
|
|
{ min: 10, text: "Building a structured understanding" },
|
|
{ min: 25, text: "Identifying what is known and still unclear" },
|
|
{ min: 45, text: "Selecting the next useful question" },
|
|
];
|
|
|
|
const UPDATE_MESSAGES = [
|
|
{ min: 0, text: "Considering your answer" },
|
|
{ min: 10, text: "Updating the situation" },
|
|
{ min: 25, text: "Checking what changed" },
|
|
{ min: 45, text: "Choosing the next question" },
|
|
];
|
|
|
|
function useLoadingStatus(messages, isLoading) {
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const startRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (isLoading) {
|
|
startRef.current = Date.now();
|
|
const iv = setInterval(() => {
|
|
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
|
|
}, 1000);
|
|
return () => clearInterval(iv);
|
|
} else {
|
|
setElapsed(0);
|
|
startRef.current = null;
|
|
}
|
|
}, [isLoading]);
|
|
|
|
const currentMessage = useMemo(() => {
|
|
if (!messages || messages.length === 0) return "";
|
|
let msg = messages[0].text;
|
|
for (const m of messages) {
|
|
if (elapsed >= m.min) msg = m.text;
|
|
}
|
|
return msg;
|
|
}, [messages, elapsed]);
|
|
|
|
return { elapsed, currentMessage };
|
|
}
|
|
|
|
// ── Spinner component ───────────────────────────────────────
|
|
function ActivitySpinner() {
|
|
return (
|
|
<span
|
|
className="inline-block h-4 w-4 border-[2px] border-gray-300 border-t-gray-600 rounded-full"
|
|
style={{ animation: "spin 1s linear infinite" }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Situation card ────────────────────────────────────────────
|
|
function SituationCard({ centralStatement }) {
|
|
if (!centralStatement) return null;
|
|
return (
|
|
<div className="investigation-card rounded-lg border border-gray-200 bg-white p-5">
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
|
Your situation
|
|
</h2>
|
|
<p className="text-base leading-relaxed text-gray-900">{centralStatement}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Current understanding card ────────────────────────────────
|
|
function CurrentUnderstanding({ currentSummary }) {
|
|
const summary = resolveCurrentSummary(currentSummary);
|
|
|
|
if (!summary) return null;
|
|
|
|
return (
|
|
<div className="investigation-card rounded-lg border border-gray-200 bg-white p-5">
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
|
What we've established
|
|
</h2>
|
|
<p className="text-sm leading-relaxed text-gray-700">{summary}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Current investigation card (prominent) ─────────────────────
|
|
function CurrentInvestigationCard({ selectedQuestion, graph }) {
|
|
if (!selectedQuestion) return null;
|
|
|
|
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
|
|
if (!q) return null;
|
|
|
|
const activeNode = graph?.activeUnknownNodeId
|
|
? graph.nodes.find((n) => n.id === graph.activeUnknownNodeId)
|
|
: null;
|
|
|
|
return (
|
|
<div className="investigation-card rounded-lg border-2 border-green-300 bg-green-50 p-6">
|
|
<h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-green-700">
|
|
Current investigation
|
|
</h2>
|
|
<p className="text-xl font-semibold leading-snug text-gray-900">{q}</p>
|
|
{activeNode?.description && activeNode.description !== activeNode.label && (
|
|
<div className="mt-4 space-y-1">
|
|
<h3 className="text-xs font-bold uppercase tracking-wide text-green-800">Why we are asking this</h3>
|
|
<p className="text-sm text-gray-700">{activeNode.description}</p>
|
|
</div>
|
|
)}
|
|
{graph?.activeUnknownNodeId && activeNode && (
|
|
<div className="mt-2 space-y-1">
|
|
<h3 className="text-xs font-bold uppercase tracking-wide text-green-800">What we are investigating</h3>
|
|
<p className="text-sm text-gray-700">
|
|
Understanding whether "{activeNode.label}" affects the confidence in this situation.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Outcome helpers ───────────────────────────────────────────
|
|
|
|
function hasGenuineCompletion(graph) {
|
|
if (!graph || !graph.nodes?.length) return false;
|
|
const resolvedIds = new Set(graph.resolvedNodeIds || []);
|
|
const unresolvedCount = graph.nodes.filter(
|
|
(n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id),
|
|
).length;
|
|
if (unresolvedCount > 0) return false;
|
|
if (graph.activeUnknownNodeId) {
|
|
const active = graph.nodes.find((n) => n.id === graph.activeUnknownNodeId);
|
|
if (active && active.status !== "resolved" && !resolvedIds.has(active.id)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Current focus card ───────────────────────────────────────
|
|
function CurrentFocusCard({ graph }) {
|
|
const activeNode = graph?.activeUnknownNodeId
|
|
? graph.nodes.find((n) => n.id === graph.activeUnknownNodeId)
|
|
: null;
|
|
|
|
if (!graph || !activeNode) return null;
|
|
|
|
return (
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
|
|
<h3 className="mb-1 text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
Current focus
|
|
</h3>
|
|
<p className="text-sm leading-relaxed text-gray-700">
|
|
We are investigating one part of your situation at a time.
|
|
{activeNode && (
|
|
<>
|
|
<br />
|
|
Right now we are trying to understand{" "}
|
|
<strong>{activeNode.label}</strong>.
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Investigation history card (readable notebook style) ───────
|
|
function InvestigationHistoryCard({ turn }) {
|
|
const isCollapsed = turn._collapsed;
|
|
|
|
return (
|
|
<details
|
|
className="rounded-lg border border-gray-100 bg-gray-50/60"
|
|
key={turn.id}
|
|
open={!isCollapsed}
|
|
>
|
|
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
|
|
{isCollapsed ? "Q: " : "✓ "}
|
|
{turn.question.length > 60 && !isCollapsed
|
|
? turn.question.slice(0, 60) + "…"
|
|
: turn.question}
|
|
{!isCollapsed && (
|
|
<span className="ml-2 text-xs font-normal text-gray-400">— answered</span>
|
|
)}
|
|
</summary>
|
|
|
|
<div className="px-4 pb-3 pt-1 space-y-2">
|
|
<p><strong>Your answer</strong></p>
|
|
<p className="text-gray-700">{turn.answer}</p>
|
|
|
|
{turn.acknowledgement && (
|
|
<>
|
|
<hr className="border-gray-200" />
|
|
<p><strong>What changed</strong></p>
|
|
<p className="italic text-gray-500">{turn.acknowledgement}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
// ── Investigation history section ─────────────────────────────
|
|
function InvestigationHistory({ turns }) {
|
|
if (!turns || turns.length === 0) return null;
|
|
|
|
const latestId = turns[turns.length - 1].id;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
|
Investigation history
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{turns.map((turn) => (
|
|
<InvestigationHistoryCard key={turn.id} turn={{ ...turn, _collapsed: turn.id !== latestId }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Investigation complete state ───────────────────────────────
|
|
function InvestigationCompleteMessage({ noQuestionReason }) {
|
|
let message = "We have established enough for now.";
|
|
if (noQuestionReason) {
|
|
const reason = String(noQuestionReason);
|
|
if (
|
|
reason.toLowerCase().includes("resolved") ||
|
|
reason.toLowerCase().includes("satisfied") ||
|
|
reason.toLowerCase().includes("complete")
|
|
) {
|
|
message = "You have provided enough information. The situation has been fully investigated.";
|
|
} else if (reason.toLowerCase().includes("insufficient")) {
|
|
message = "There is not yet enough evidence to guide the next step. Your original situation will remain our focus when new information becomes available.";
|
|
} else {
|
|
message = reason;
|
|
}
|
|
}
|
|
return (
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center transition-opacity duration-300">
|
|
<p className="text-sm text-gray-600">{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Loading overlay (for both start and update) ───────────────
|
|
function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) {
|
|
if (!isLoading) return null;
|
|
|
|
const messages = variant === "update" ? UPDATE_MESSAGES : INITIAL_MESSAGES;
|
|
let statusText = messages[0].text;
|
|
for (const m of messages) {
|
|
if (elapsed >= m.min) statusText = m.text;
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-gray-200 bg-blue-50 px-5 py-6" role="status" aria-busy="true">
|
|
<div className="flex items-center gap-3">
|
|
<ActivitySpinner />
|
|
<span className="text-base font-medium text-blue-900">Working through your situation</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-blue-700">{statusText}</p>
|
|
<p className="mt-1 text-xs text-blue-500" aria-live="polite">
|
|
This has been running for {elapsed}s.
|
|
{variant === "initial" && elapsed >= 45 && (
|
|
<span className="block mt-1">This can take around a minute with the current local model.</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Update acknowledgement ────────────────────────────────────
|
|
function UpdateAcknowledgement({ updateResult }) {
|
|
if (!updateResult) return null;
|
|
|
|
const summary = updateResult.summary;
|
|
const hasResolvedNodes = updateResult.resolvedUnknownNodeIds?.length > 0;
|
|
const hasAffectedNodes = updateResult.affectedNodeIds?.length > 0;
|
|
const graph = updateResult.updatedSituationGraph;
|
|
|
|
function getNodeText(nodeId) {
|
|
if (!graph?.nodes) return String(nodeId);
|
|
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
if (node) {
|
|
const parts = [node.label];
|
|
if (node.status !== "resolved") {
|
|
parts.push(node.status);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
return String(nodeId);
|
|
}
|
|
|
|
let changedText;
|
|
if (hasResolvedNodes) {
|
|
const items = [];
|
|
for (const id of updateResult.resolvedUnknownNodeIds.slice(0, 3)) {
|
|
items.push(getNodeText(id));
|
|
}
|
|
if (updateResult.resolvedUnknownNodeIds.length > 3) {
|
|
items.push(`and ${updateResult.resolvedUnknownNodeIds.length - 3} more resolved`);
|
|
}
|
|
changedText = items.join(". ") + ".";
|
|
} else if (hasAffectedNodes) {
|
|
const items = [];
|
|
for (const id of updateResult.affectedNodeIds.slice(0, 3)) {
|
|
items.push(getNodeText(id));
|
|
}
|
|
if (updateResult.affectedNodeIds.length > 3) {
|
|
items.push(`and ${updateResult.affectedNodeIds.length - 3} more affected`);
|
|
}
|
|
changedText = items.join(". ") + ".";
|
|
} else if (updateResult.changesApplied) {
|
|
const ca = updateResult.changesApplied;
|
|
const parts = [];
|
|
if (ca.addedNodeCount) parts.push(`${ca.addedNodeCount} node(s) added`);
|
|
if (ca.updatedNodeCount) parts.push(`${ca.updatedNodeCount} node(s) updated`);
|
|
if (ca.addedEdgeCount) parts.push(`${ca.addedEdgeCount} edge(s) added`);
|
|
if (ca.removedEdgeCount) parts.push(`${ca.removedEdgeCount} edge(s) removed`);
|
|
changedText = parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
const displayMessage = summary || changedText || "Your answer has been added to the investigation.";
|
|
|
|
return (
|
|
<div className="rounded-lg border border-blue-100 bg-blue-50/80 px-5 py-3 text-sm text-blue-900">
|
|
{displayMessage}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Developer details disclosure ──────────────────────────────
|
|
function DeveloperDetails({ graph, selectedQuestion, diagnostics, newlySurfacedNodeIds, updateResult }) {
|
|
return (
|
|
<details className="rounded-lg border border-gray-200 bg-gray-50">
|
|
<summary className="cursor-pointer px-5 py-3 text-sm font-medium text-gray-600 hover:text-gray-800">
|
|
Developer details
|
|
</summary>
|
|
<div className="border-t border-gray-200 px-5 pb-4 pt-3 space-y-4">
|
|
{graph && (
|
|
<SituationGraphView
|
|
situationGraph={graph}
|
|
selectedQuestion={selectedQuestion}
|
|
newlySurfacedNodeIds={newlySurfacedNodeIds}
|
|
/>
|
|
)}
|
|
{updateResult && (
|
|
<GraphUpdateView updateResult={{ ...updateResult, previousSituationGraph: graph }} />
|
|
)}
|
|
{diagnostics && <DiagnosticsView result={{ diagnostics }} />}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
// ── Main workspace component ──────────────────────────────────
|
|
export default function ReasoningWorkspace({
|
|
status,
|
|
updateStatus,
|
|
result,
|
|
answer,
|
|
setAnswer,
|
|
onAnswerSubmit,
|
|
lastSubmittedAnswer,
|
|
}) {
|
|
const [investigationHistory, setInvestigationHistory] = useState([]);
|
|
const turnCounter = useRef(0);
|
|
const pendingTurnRef = useRef(null);
|
|
|
|
// Capture the current selected question at submit time (not from a stale ref)
|
|
const capturePendingTurn = (selectedQuestion, answerText) => {
|
|
if (!selectedQuestion || !answerText?.trim()) return null;
|
|
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
|
|
if (!q) return null;
|
|
turnCounter.current += 1;
|
|
return {
|
|
id: `turn-${turnCounter.current}`,
|
|
question: q,
|
|
answer: answerText.trim(),
|
|
acknowledgement: null,
|
|
};
|
|
};
|
|
|
|
// Append the captured pending turn to history after a successful update only
|
|
useEffect(() => {
|
|
const pending = pendingTurnRef.current;
|
|
if (!pending || updateStatus !== "success") return;
|
|
|
|
setInvestigationHistory((prev) => [
|
|
...prev,
|
|
{ ...pending, acknowledgement: result?.summary || null },
|
|
]);
|
|
pendingTurnRef.current = null;
|
|
}, [updateStatus, result]);
|
|
|
|
const handleUpdateCaptureAndSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!answer?.trim() || !result?.selectedQuestion) return;
|
|
pendingTurnRef.current = capturePendingTurn(result.selectedQuestion, answer);
|
|
await onAnswerSubmit(e);
|
|
};
|
|
|
|
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
|
|
INITIAL_MESSAGES,
|
|
status === "loading"
|
|
);
|
|
|
|
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
|
|
UPDATE_MESSAGES,
|
|
updateStatus === "loading"
|
|
);
|
|
|
|
const isUpdating = updateStatus === "loading";
|
|
const hasSelectedQuestion = Boolean(result?.selectedQuestion);
|
|
|
|
const canAnswer =
|
|
status === "success" &&
|
|
!isUpdating &&
|
|
Boolean(result?.situationGraph) &&
|
|
hasSelectedQuestion;
|
|
|
|
const selectedQ = result?.selectedQuestion ?? null;
|
|
const graph = result?.situationGraph ?? null;
|
|
const diagnostics = result?.diagnostics ?? null;
|
|
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
|
|
const noQuestionReason = diagnostics?.noQuestionReason ?? null;
|
|
|
|
const genuineCompletion = hasGenuineCompletion(graph);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* ── Loading overlays ─────────────────────────────── */}
|
|
<LoadingOverlay
|
|
isLoading={status === "loading"}
|
|
elapsed={startElapsed}
|
|
currentMessage={startMsg}
|
|
variant="initial"
|
|
/>
|
|
{updateStatus === "loading" && (
|
|
<div className="h-px bg-gray-100" />
|
|
)}
|
|
<LoadingOverlay
|
|
isLoading={updateStatus === "loading"}
|
|
elapsed={updateElapsed}
|
|
currentMessage={updateMsg}
|
|
variant="update"
|
|
/>
|
|
|
|
{/* ── User-facing workspace ────────────────────────── */}
|
|
{(status === "success" || status === "error") && !graph ? (
|
|
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
|
{noQuestionReason
|
|
? "Validation failed — no structured graph output was produced."
|
|
: "The analysis completed but did not produce a structured result."}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Post-update acknowledgement */}
|
|
{updateStatus === "success" && canAnswer && <UpdateAcknowledgement updateResult={result} />}
|
|
|
|
{/* Completion state */}
|
|
{status === "success" && !canAnswer && graph && genuineCompletion && (
|
|
<InvestigationCompleteMessage noQuestionReason={noQuestionReason} />
|
|
)}
|
|
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && (
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center">
|
|
<p className="text-sm text-gray-600">There is no further question the engine can justify at the moment.</p>
|
|
<p className="mt-1 text-xs text-gray-500">More evidence may be needed before a next step is clear.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Situation context */}
|
|
{graph && <SituationCard centralStatement={graph.centralStatement} />}
|
|
{graph && <CurrentUnderstanding currentSummary={graph.currentSummary} />}
|
|
|
|
{/* ── Investigation history (below situation, above current focus) ─ */}
|
|
<InvestigationHistory turns={investigationHistory} />
|
|
|
|
{/* Active investigation (only when we have a question to answer) */}
|
|
{canAnswer && <CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />}
|
|
{canAnswer && <CurrentFocusCard graph={graph} />}
|
|
|
|
{/* ── Answer form ──────────────────────────────── */}
|
|
{canAnswer && (
|
|
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
|
|
<div>
|
|
<label htmlFor="rw-answer" className="mb-2 block text-sm font-medium text-gray-700">
|
|
Your response
|
|
</label>
|
|
<textarea
|
|
id="rw-answer"
|
|
value={answer}
|
|
onChange={(e) => setAnswer(e.target.value)}
|
|
rows={4}
|
|
disabled={updateStatus === "loading"}
|
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
|
|
placeholder="Enter the answer to the selected question..."
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-gray-400">
|
|
{updateStatus === "loading" ? "Updating..." : "One update turn only in this prototype."}
|
|
</p>
|
|
<button
|
|
type="submit"
|
|
disabled={updateStatus === "loading" || !answer.trim()}
|
|
className="rounded-lg bg-blue-700 px-5 py-2 text-sm font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{updateStatus === "loading" ? "Updating..." : "Update situation"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* ── Developer details (collapsed by default) ─── */}
|
|
{(status === "success" || status === "error") && graph && (
|
|
<DeveloperDetails
|
|
graph={graph}
|
|
selectedQuestion={selectedQ}
|
|
diagnostics={diagnostics}
|
|
newlySurfacedNodeIds={newlySurfacedNodeIds}
|
|
updateResult={updateStatus === "success" ? result : null}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── Errors (always visible above debug) ─────────── */}
|
|
{(status === "error" || updateStatus === "error") && (
|
|
<div className="space-y-3">
|
|
{status === "error" && result?.error && (
|
|
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
|
|
Error: {result.error}
|
|
</div>
|
|
)}
|
|
{updateStatus === "error" && result?.updateError && (
|
|
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
Update error: {result.updateError.error || JSON.stringify(result.updateError)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary };
|