+
{content.fields.map((field) => (
-
+
{field.label}
- {field.value}
+ {editing && isPending ? (
+ field.editable ? (
+
+ setFieldValue(field.key, event.target.value)
+ }
+ style={{ fontSize: 11 }}
+ aria-label={`Edit ${field.label}`}
+ />
+ ) : (
+
+ {field.value}
+
+ )
+ ) : (
+ {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ beginEditing();
+ }
+ }
+ : undefined
+ }
+ aria-label={
+ field.editable ? `Edit ${field.label}` : undefined
+ }
+ >
+ {field.value}
+
+ )}
))}
)}
+
+ {traceSections.length > 0 && (
+
+
:
}
+ onClick={() => setTraceOpen((value) => !value)}
+ style={{ paddingInline: 0, height: 24 }}
+ >
+ Operational trace
+
+ {traceOpen && (
+
+ {traceSections.map((section) => (
+
+ ))}
+
+ )}
+
+ )}
+
onApprove?.(proposal)}
- disabled={disabled}
+ onClick={() => onApprove?.(proposal, changedFields)}
+ disabled={disabled || !isPending}
>
- Approve
+ {approveLabel}
- onReject?.(proposal)} disabled={disabled}>
+ {hasEditableFields && isPending && (
+
+ {editing ? "Cancel edits" : "Edit details"}
+
+ )}
+ onReject?.(proposal)}
+ disabled={disabled || !isPending}
+ >
Reject
diff --git a/client/src/components/chat/AgentProposalCard.test.js b/client/src/components/chat/AgentProposalCard.test.js
new file mode 100644
index 00000000..515a09b1
--- /dev/null
+++ b/client/src/components/chat/AgentProposalCard.test.js
@@ -0,0 +1,137 @@
+import React from "react";
+import { fireEvent, render, screen, cleanup } from "@testing-library/react";
+
+import AgentProposalCard from "./AgentProposalCard";
+
+jest.mock("antd", () => ({
+ TextArea: ({ autoSize, children, ...props }) => (
+
+ ),
+ Button: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ Input: {
+ TextArea: ({ autoSize, children, ...props }) => (
+
+ ),
+ },
+ Space: ({ children }) =>
{children}
,
+ Tag: ({ children, ...props }) =>
{children} ,
+ Typography: {
+ Text: ({ children, strong, type, ...props }) => (
+
{children}
+ ),
+ },
+}));
+
+describe("AgentProposalCard", () => {
+ it("lets users click fields or tap Edit details before approval", () => {
+ const onApprove = jest.fn();
+ const proposal = {
+ id: 12,
+ approval_status: "pending",
+ type: "start_training_run",
+ config_preset: "/project/configs/base.yaml",
+ image_path: "/project/subsets/image",
+ label_path: "/project/subsets/seg",
+ output_path: "/project/outputs/original",
+ autopick_parameters: true,
+ };
+
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "Edit details" }));
+ fireEvent.change(screen.getByLabelText("Edit Output"), {
+ target: { value: "/project/outputs/edited" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Approve with edits" }));
+
+ expect(onApprove).toHaveBeenCalledWith(proposal, {
+ output_path: "/project/outputs/edited",
+ });
+
+ cleanup();
+ render(
);
+ fireEvent.click(screen.getByText("/project/outputs/original"));
+ fireEvent.change(screen.getByLabelText("Edit Output"), {
+ target: { value: "/project/outputs/edited-direct" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Approve with edits" }));
+
+ expect(onApprove).toHaveBeenLastCalledWith(proposal, {
+ output_path: "/project/outputs/edited-direct",
+ });
+ });
+
+ it("exposes client-effect paths as editable approval fields", () => {
+ const onApprove = jest.fn();
+ const proposal = {
+ id: 13,
+ approval_status: "pending",
+ type: "run_client_effects",
+ item_label: "Run inference",
+ client_effects: {
+ set_inference_output_path: "/project/outputs/inference",
+ },
+ };
+
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "Edit details" }));
+ fireEvent.change(screen.getByLabelText("Edit Output"), {
+ target: { value: "/project/outputs/inference-edited" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Approve with edits" }));
+
+ expect(onApprove).toHaveBeenCalledWith(proposal, {
+ inference_output_path: "/project/outputs/inference-edited",
+ });
+ });
+
+ it("disables actions after decision is recorded", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByRole("button", { name: "Approve" }).disabled).toBe(true);
+ expect(screen.getByRole("button", { name: "Reject" }).disabled).toBe(true);
+ expect(screen.queryByRole("button", { name: "Edit details" })).toBeNull();
+ });
+
+ it("keeps long labels and specialist agent badges visible", () => {
+ render(
+
,
+ );
+
+ expect(
+ screen.getByText(
+ "specialist_inference_validation_and_retraining_review_pipeline",
+ ).className,
+ ).toContain("workflow-proposal-card__type-tag");
+ expect(screen.getByText("Inference Specialist")).toBeTruthy();
+ expect(screen.getByText("bar")).toBeTruthy();
+ });
+});
diff --git a/client/src/components/chat/AgentVisuals.js b/client/src/components/chat/AgentVisuals.js
new file mode 100644
index 00000000..4cd04f97
--- /dev/null
+++ b/client/src/components/chat/AgentVisuals.js
@@ -0,0 +1,174 @@
+import React from "react";
+import { Tag } from "antd";
+import {
+ BarChartOutlined,
+ BugOutlined,
+ ExperimentOutlined,
+ EyeOutlined,
+ FileDoneOutlined,
+ FolderOpenOutlined,
+ ProjectOutlined,
+ ThunderboltOutlined,
+} from "@ant-design/icons";
+
+const ICONS = {
+ bar_chart: BarChartOutlined,
+ bug: BugOutlined,
+ experiment: ExperimentOutlined,
+ eye: EyeOutlined,
+ file_done: FileDoneOutlined,
+ folder: FolderOpenOutlined,
+ project: ProjectOutlined,
+ thunderbolt: ThunderboltOutlined,
+};
+
+const ALLOWED_BORDER_STYLES = new Set([
+ "solid",
+ "dotted",
+ "double",
+ "dashed",
+ "thick",
+ "dashdot",
+ "top",
+ "rail",
+]);
+
+const normalizeAgentText = (value) => {
+ if (typeof value !== "string") return "";
+ return value.trim();
+};
+
+const normalizeAgentColor = (value) => {
+ const text = normalizeAgentText(value);
+ if (!text) return DEFAULT_AGENT_VISUAL.color;
+ return text;
+};
+
+const normalizeAgentIconKey = (value) => {
+ const text = normalizeAgentText(value);
+ return ICONS[text] ? text : DEFAULT_AGENT_VISUAL.icon_key;
+};
+
+const normalizeBorderStyle = (value) => {
+ const text = normalizeAgentText(value).toLowerCase();
+ return ALLOWED_BORDER_STYLES.has(text)
+ ? text
+ : DEFAULT_AGENT_VISUAL.border_style;
+};
+
+export const DEFAULT_AGENT_VISUAL = {
+ label: "Project Manager",
+ shortLabel: "PM",
+ color: "#111827",
+ icon_key: "project",
+ border_style: "solid",
+};
+
+export function getAgentVisual(agent = {}) {
+ const explicitLabel =
+ normalizeAgentText(agent.agent_label) || normalizeAgentText(agent.label);
+ const shortLabel =
+ normalizeAgentText(agent.agent_short_label) ||
+ normalizeAgentText(agent.short_label) ||
+ normalizeAgentText(agent.shortLabel);
+
+ return {
+ label: explicitLabel || DEFAULT_AGENT_VISUAL.label,
+ shortLabel: shortLabel || explicitLabel || DEFAULT_AGENT_VISUAL.shortLabel,
+ color: normalizeAgentColor(agent.agent_color || agent.color),
+ iconKey: normalizeAgentIconKey(agent.agent_icon_key || agent.icon_key),
+ borderStyle: normalizeBorderStyle(
+ agent.agent_border_style || agent.border_style,
+ ),
+ };
+}
+
+export function getAgentBorderStyles(borderStyle, color, width = 4) {
+ const accent = color || DEFAULT_AGENT_VISUAL.color;
+ const base = {
+ borderLeft: `${width}px solid ${accent}`,
+ };
+
+ switch (borderStyle) {
+ case "dotted":
+ return { borderLeft: `${width}px dotted ${accent}` };
+ case "double":
+ return { borderLeft: `${Math.max(width + 1, 5)}px double ${accent}` };
+ case "dashed":
+ return { borderLeft: `${width}px dashed ${accent}` };
+ case "thick":
+ return { borderLeft: `${Math.max(width + 2, 6)}px solid ${accent}` };
+ case "dashdot":
+ return {
+ borderLeft: `${width}px solid ${accent}`,
+ borderTop: `2px dashed ${accent}`,
+ };
+ case "top":
+ return {
+ borderLeft: `${width}px solid ${accent}`,
+ borderTop: `3px solid ${accent}`,
+ };
+ case "rail":
+ return {
+ borderLeft: `${width}px solid ${accent}`,
+ boxShadow: `inset ${width + 2}px 0 0 rgba(0, 166, 166, 0.14)`,
+ };
+ default:
+ return base;
+ }
+}
+
+function AgentIcon({ iconKey }) {
+ const Icon = ICONS[iconKey] || ICONS.project;
+ return
;
+}
+
+function AgentBadge({
+ agent,
+ label,
+ color,
+ iconKey,
+ compact = false,
+ labelMode = "short",
+}) {
+ const visual = getAgentVisual({
+ ...(agent || {}),
+ label,
+ color,
+ icon_key: iconKey,
+ });
+ const displayLabel =
+ labelMode === "full" ? visual.label : visual.shortLabel || visual.label;
+
+ return (
+
+
+
+ {displayLabel}
+
+
+ );
+}
+
+export default AgentBadge;
diff --git a/client/src/components/chat/AssistantActionCard.js b/client/src/components/chat/AssistantActionCard.js
new file mode 100644
index 00000000..ed106520
--- /dev/null
+++ b/client/src/components/chat/AssistantActionCard.js
@@ -0,0 +1,138 @@
+import React from "react";
+import { Button, Tag, Typography } from "antd";
+import AgentBadge, {
+ DEFAULT_AGENT_VISUAL,
+ getAgentBorderStyles,
+ getAgentVisual,
+} from "./AgentVisuals";
+
+const { Text } = Typography;
+const MONO_FONT =
+ "'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
+
+const RISK_LABELS = {
+ read_only: "view only",
+ prefills_form: "sets form",
+ loads_editor: "opens editor",
+ runs_job: "runs job",
+ writes_workflow_record: "writes record",
+ exports_evidence: "exports",
+};
+
+function AssistantActionCard({ action, onRun, disabled = false }) {
+ const buttonType = action?.variant === "primary" ? "primary" : "default";
+ const riskLabel = RISK_LABELS[action?.risk_level] || action?.risk_level;
+ const isDisabled = disabled || Boolean(action?.disabled_reason);
+ const specialist = action?.specialist_agent || {};
+ const agentVisual = getAgentVisual({
+ ...specialist,
+ agent_label: action?.agent_label,
+ agent_color: action?.agent_color,
+ agent_icon_key: action?.agent_icon_key,
+ agent_border_style: action?.agent_border_style,
+ });
+ const summaryFields = Array.isArray(action?.summary_fields)
+ ? action.summary_fields.slice(0, 3)
+ : [];
+
+ return (
+
+
+
+
+
+
+ {action?.label || "Action"}
+
+ {riskLabel && (
+
+ {riskLabel}
+
+ )}
+
+ {action?.description && (
+
+ {action.description}
+
+ )}
+ {summaryFields.length > 0 && (
+
+ {summaryFields.map((field, index) => (
+
+
+ {field.label}
+
+
+ {field.value}
+
+
+ ))}
+
+ )}
+ {action?.disabled_reason && (
+
+ {action.disabled_reason}
+
+ )}
+
+
onRun?.(action)}
+ disabled={isDisabled}
+ style={{ flexShrink: 0 }}
+ >
+ {action?.run_label || "Run in app"}
+
+
+
+ );
+}
+
+export default AssistantActionCard;
diff --git a/client/src/components/chat/AssistantCommandCard.js b/client/src/components/chat/AssistantCommandCard.js
new file mode 100644
index 00000000..9a5d33ba
--- /dev/null
+++ b/client/src/components/chat/AssistantCommandCard.js
@@ -0,0 +1,118 @@
+import React, { useState } from "react";
+import { Button, Tag, Typography } from "antd";
+
+const { Text } = Typography;
+const MONO_FONT =
+ "'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', monospace";
+
+const RISK_LABELS = {
+ read_only: "view only",
+ prefills_form: "sets form",
+ loads_editor: "opens editor",
+ runs_job: "runs job",
+ writes_workflow_record: "writes record",
+ exports_evidence: "exports",
+};
+
+function AssistantCommandCard({ command, onRun, disabled = false }) {
+ const [showDetails, setShowDetails] = useState(false);
+ const riskLabel = RISK_LABELS[command?.risk_level] || command?.risk_level;
+
+ return (
+
+
+
+
+ {command?.title || "App Command"}
+
+ {riskLabel && (
+
+ {riskLabel}
+
+ )}
+
+
+ setShowDetails((current) => !current)}
+ >
+ {showDetails ? "Hide route" : "Route"}
+
+ onRun?.(command)}
+ disabled={disabled}
+ >
+ {command?.run_label || "Execute"}
+
+
+
+
+ {command?.description && (
+
+ {command.description}
+
+ )}
+ {showDetails && (
+
+ {command?.command}
+
+ )}
+
+
+ );
+}
+
+export default AssistantCommandCard;
diff --git a/client/src/components/chat/AssistantTrace.js b/client/src/components/chat/AssistantTrace.js
new file mode 100644
index 00000000..0986fad5
--- /dev/null
+++ b/client/src/components/chat/AssistantTrace.js
@@ -0,0 +1,131 @@
+import React, { useState } from "react";
+import { Button, Typography } from "antd";
+import { DownOutlined, RightOutlined } from "@ant-design/icons";
+import AgentBadge, {
+ getAgentBorderStyles,
+ getAgentVisual,
+} from "./AgentVisuals";
+
+const { Text } = Typography;
+
+const CATEGORY_LABELS = {
+ checked: "Checked",
+ inferred: "Inferred",
+ proposed: "Proposed",
+ blocked_by: "Blocked",
+};
+
+const TRACE_SECTION_LABELS = {
+ checked: "Inspected facts",
+ inferred: "Inferred context",
+ proposed: "Decision",
+ blocked_by: "Policy gate",
+};
+
+const getCategory = (item = {}) => item.category || item.status || "checked";
+
+function summarizeTraceSections(traceItems = []) {
+ const grouped = {};
+ traceItems.forEach((item) => {
+ const normalizedCategory = getCategory(item);
+ if (!grouped[normalizedCategory]) grouped[normalizedCategory] = [];
+ grouped[normalizedCategory].push(item);
+ });
+ return Object.keys(TRACE_SECTION_LABELS)
+ .map((category) => ({
+ key: category,
+ label: TRACE_SECTION_LABELS[category],
+ items: grouped[category] || [],
+ }))
+ .concat(
+ Object.keys(grouped)
+ .filter((key) => !(key in TRACE_SECTION_LABELS))
+ .map((key) => ({
+ key,
+ label: CATEGORY_LABELS[key] || key,
+ items: grouped[key] || [],
+ })),
+ )
+ .filter((section) => section.items.length > 0);
+}
+
+function AssistantTrace({ trace = [] }) {
+ const [open, setOpen] = useState(false);
+ const items = Array.isArray(trace) ? trace.filter(Boolean) : [];
+ if (!items.length) return null;
+ const traceSections = summarizeTraceSections(items);
+
+ return (
+
+
:
}
+ onClick={() => setOpen((value) => !value)}
+ style={{
+ height: 24,
+ paddingInline: 0,
+ color: "#6b7280",
+ fontSize: 12,
+ fontWeight: 500,
+ }}
+ >
+ Operational trace
+
+ {open && (
+
+ {traceSections.map((section) => (
+
+
+ {section.label}
+
+
+ {section.items.map((item, index) => (
+
+
+
+
+ {CATEGORY_LABELS[getCategory(item)] ||
+ getCategory(item) ||
+ "Step"}
+
+
+ {item.label || "Operational step"}
+
+
+ {item.detail && (
+
+ {item.detail}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default AssistantTrace;
diff --git a/client/src/components/workflow/StageHeader.js b/client/src/components/workflow/StageHeader.js
new file mode 100644
index 00000000..c8853b4b
--- /dev/null
+++ b/client/src/components/workflow/StageHeader.js
@@ -0,0 +1,36 @@
+import React from "react";
+import { Button, Space, Tooltip, Typography } from "antd";
+
+import { getStageMeta } from "../../design/workflowDesignSystem";
+
+const { Text } = Typography;
+
+function StageHeader({
+ stage,
+ title = "Segmentation Workflow",
+ subtitle,
+ actionLabel,
+ onAction,
+}) {
+ const meta = getStageMeta(stage);
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+ {actionLabel && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
+
+export default StageHeader;
diff --git a/client/src/components/workflow/WorkflowEvidencePanel.js b/client/src/components/workflow/WorkflowEvidencePanel.js
new file mode 100644
index 00000000..2208849d
--- /dev/null
+++ b/client/src/components/workflow/WorkflowEvidencePanel.js
@@ -0,0 +1,1032 @@
+import React, { useMemo, useState } from "react";
+import { Button, Card, Empty, Input, Space, Tag, Typography } from "antd";
+import {
+ computeWorkflowEvaluationResult,
+ exportWorkflowBundle,
+} from "../../api";
+import { useWorkflow } from "../../contexts/WorkflowContext";
+
+const { Text } = Typography;
+
+const METRIC_ROWS = [
+ { key: "dice", label: "Dice", direction: "higher" },
+ { key: "iou", label: "IoU", direction: "higher" },
+ { key: "voxel_accuracy", label: "Voxel accuracy", direction: "higher" },
+ {
+ key: "adapted_rand_error",
+ label: "Adapted Rand error",
+ direction: "lower",
+ },
+ { key: "vi_total", label: "VI total", direction: "lower" },
+];
+
+const EMPTY_ARRAY = [];
+
+function latestByCreatedAt(items = []) {
+ return (
+ [...items].sort((left, right) => {
+ const leftTime = new Date(left.created_at || 0).getTime();
+ const rightTime = new Date(right.created_at || 0).getTime();
+ if (leftTime !== rightTime) return rightTime - leftTime;
+ return (right.id || 0) - (left.id || 0);
+ })[0] || null
+ );
+}
+
+function normalizeText(value) {
+ return String(value || "").toLowerCase();
+}
+
+function findArtifactByPath(artifacts, path) {
+ if (!path) return null;
+ return artifacts.find((artifact) => artifact.path === path) || null;
+}
+
+function findArtifactByHints(artifacts, hints) {
+ return (
+ artifacts.find((artifact) => {
+ const haystack = [
+ artifact.artifact_type,
+ artifact.role,
+ artifact.name,
+ artifact.path,
+ artifact.metadata?.source_payload_key,
+ ]
+ .map(normalizeText)
+ .join(" ");
+ return hints.some((hint) => haystack.includes(hint));
+ }) || null
+ );
+}
+
+function formatMetric(value, { signed = false } = {}) {
+ if (value === null || value === undefined || value === "") return "-";
+ if (typeof value !== "number") return String(value);
+ const rounded = Math.abs(value) >= 10 ? value.toFixed(2) : value.toFixed(4);
+ const trimmed = rounded.replace(/\.?0+$/, "");
+ return signed && value > 0 ? `+${trimmed}` : trimmed;
+}
+
+function formatPath(path) {
+ if (!path) return "Not recorded";
+ return path;
+}
+
+function formatShortPath(path) {
+ if (!path) return "Not selected";
+ const parts = String(path)
+ .split(/[\\/]+/)
+ .filter(Boolean);
+ if (parts.length <= 2) return parts.join("/");
+ return parts.slice(-2).join("/");
+}
+
+function statusForPath(artifacts, path) {
+ if (!path) return { label: "Needed", color: "red" };
+ const artifact = findArtifactByPath(artifacts, path);
+ if (!artifact) return { label: "Selected", color: "default" };
+ return artifact.exists
+ ? { label: "Ready", color: "green" }
+ : { label: "Can't find file", color: "orange" };
+}
+
+function compactObject(value) {
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ ([, entry]) => entry !== undefined && entry !== null && entry !== "",
+ ),
+ );
+}
+
+function summarizeEvidence({
+ workflow,
+ artifacts,
+ modelRuns,
+ correctionSets,
+ evaluationResults,
+}) {
+ const latestEvaluation = latestByCreatedAt(evaluationResults);
+ const latestCorrection = latestByCreatedAt(correctionSets);
+ const completedInferenceRuns = modelRuns.filter(
+ (run) =>
+ run.run_type === "inference" &&
+ run.status === "completed" &&
+ run.output_path,
+ );
+ const baselineRun = completedInferenceRuns[0] || null;
+ const candidateRun =
+ completedInferenceRuns.length > 1
+ ? completedInferenceRuns[completedInferenceRuns.length - 1]
+ : null;
+ const inferenceArtifacts = artifacts.filter(
+ (artifact) => artifact.artifact_type === "inference_output",
+ );
+ const correctionArtifacts = artifacts.filter(
+ (artifact) => artifact.artifact_type === "correction_set",
+ );
+ const labelArtifacts = artifacts.filter((artifact) =>
+ ["label_volume", "mask_volume", "dataset"].includes(artifact.artifact_type),
+ );
+ const baselineArtifact = findArtifactByHints(inferenceArtifacts, [
+ "baseline",
+ "initial",
+ ]);
+ const candidateArtifact = findArtifactByHints(inferenceArtifacts, [
+ "candidate",
+ "after",
+ "result_xy",
+ ]);
+ const correctedArtifact = findArtifactByHints(correctionArtifacts, [
+ "corrected",
+ "correction",
+ ]);
+ const groundTruthArtifact = findArtifactByHints(labelArtifacts, [
+ "ground",
+ "truth",
+ "label",
+ ]);
+
+ const baselinePath =
+ latestEvaluation?.metadata?.baseline_prediction_path ||
+ baselineRun?.output_path ||
+ baselineArtifact?.path ||
+ null;
+ const candidatePath =
+ latestEvaluation?.metadata?.candidate_prediction_path ||
+ candidateRun?.output_path ||
+ workflow?.inference_output_path ||
+ candidateArtifact?.path ||
+ null;
+ const correctedPath =
+ latestCorrection?.corrected_mask_path ||
+ workflow?.corrected_mask_path ||
+ correctedArtifact?.path ||
+ null;
+ const groundTruthPath =
+ latestEvaluation?.metadata?.ground_truth_path ||
+ workflow?.label_path ||
+ workflow?.mask_path ||
+ groundTruthArtifact?.path ||
+ null;
+
+ return {
+ latestEvaluation,
+ latestCorrection,
+ baselineRun,
+ candidateRun,
+ baselinePath,
+ candidatePath,
+ correctedPath,
+ groundTruthPath,
+ };
+}
+
+function parseOptionalInteger(value) {
+ if (value === null || value === undefined || value === "") return undefined;
+ const parsed = Number.parseInt(String(value), 10);
+ return Number.isNaN(parsed) ? undefined : parsed;
+}
+
+function getEvaluationOptions(workflow, latestEvaluation, evaluationInputs) {
+ const metadata = {
+ ...(workflow?.metadata || {}),
+ ...(latestEvaluation?.metadata || {}),
+ };
+ return compactObject({
+ baseline_dataset:
+ evaluationInputs.baselineDataset || metadata.baseline_dataset,
+ candidate_dataset:
+ evaluationInputs.candidateDataset || metadata.candidate_dataset,
+ ground_truth_dataset:
+ evaluationInputs.groundTruthDataset ||
+ metadata.ground_truth_dataset ||
+ metadata.label_dataset ||
+ metadata.mask_dataset,
+ crop: evaluationInputs.crop || metadata.crop || metadata.evaluation_crop,
+ baseline_channel: parseOptionalInteger(
+ evaluationInputs.baselineChannel || metadata.baseline_channel,
+ ),
+ candidate_channel: parseOptionalInteger(
+ evaluationInputs.candidateChannel || metadata.candidate_channel,
+ ),
+ ground_truth_channel: parseOptionalInteger(
+ evaluationInputs.groundTruthChannel || metadata.ground_truth_channel,
+ ),
+ });
+}
+
+function getMissingEvaluationInputs(evidence) {
+ return [
+ ["previous result", evidence.baselinePath],
+ ["new result", evidence.candidatePath],
+ ["reference mask", evidence.groundTruthPath],
+ ]
+ .filter(([, value]) => !value)
+ .map(([label]) => label);
+}
+
+function getPipelineSteps({
+ workflow,
+ artifacts,
+ modelRuns,
+ modelVersions,
+ correctionSets,
+ evaluationResults,
+ events,
+ evidence,
+}) {
+ const hasSourceVolume = Boolean(
+ workflow?.dataset_path ||
+ workflow?.image_path ||
+ artifacts.some((artifact) =>
+ ["dataset", "image_volume"].includes(artifact.artifact_type),
+ ),
+ );
+ const completedTrainingRuns = modelRuns.filter(
+ (run) => run.run_type === "training" && run.status === "completed",
+ );
+ const hasCandidatePrediction = Boolean(
+ evidence.candidateRun ||
+ (evidence.candidatePath &&
+ evidence.candidatePath !== evidence.baselinePath),
+ );
+
+ const hasBundleExport = events.some(
+ (event) => event.event_type === "workflow.bundle_exported",
+ );
+
+ return [
+ {
+ key: "data",
+ label: "Data loaded",
+ complete: hasSourceVolume && Boolean(evidence.groundTruthPath),
+ detail: hasSourceVolume
+ ? "image and reference detected"
+ : "choose image and mask",
+ },
+ {
+ key: "baseline",
+ label: "First result",
+ complete: Boolean(evidence.baselinePath),
+ detail: evidence.baselineRun
+ ? `run #${evidence.baselineRun.id}`
+ : "run model",
+ },
+ {
+ key: "proofreading",
+ label: "Edits saved",
+ complete: Boolean(evidence.correctedPath || correctionSets.length),
+ detail: correctionSets.length
+ ? `${correctionSets.length} saved edit set(s)`
+ : "save or export edits",
+ },
+ {
+ key: "training",
+ label: "Training run",
+ complete: Boolean(completedTrainingRuns.length || modelVersions.length),
+ detail: modelVersions.length
+ ? `${modelVersions.length} model version(s)`
+ : "train on edits",
+ },
+ {
+ key: "candidate",
+ label: "New result",
+ complete: hasCandidatePrediction,
+ detail: evidence.candidateRun
+ ? `run #${evidence.candidateRun.id}`
+ : "run model again",
+ },
+ {
+ key: "evaluation",
+ label: "Improvement check",
+ complete: Boolean(evaluationResults.length),
+ detail: evaluationResults.length
+ ? `${evaluationResults.length} comparison(s)`
+ : "compare results",
+ },
+ {
+ key: "bundle",
+ label: "Research report",
+ complete: hasBundleExport,
+ detail: evaluationResults.length ? "ready to export" : "compare first",
+ },
+ ];
+}
+
+function PipelineMap({ steps }) {
+ const nextBlocked = steps.find((step) => !step.complete);
+
+ return (
+
+
+ Loop progress
+
+ {nextBlocked ? `next: ${nextBlocked.label}` : "ready"}
+
+
+
+ {steps.map((step, index) => (
+
+
+ {index + 1}. {step.label}
+
+
+ {step.complete ? "complete" : step.detail}
+
+
+ ))}
+
+
+ );
+}
+
+function EvidencePathCard({ title, path, status, detail }) {
+ return (
+
+
+
+ {title}
+
+
+ {status.label}
+
+
+
+ {formatPath(path)}
+
+ {detail && (
+
+ {detail}
+
+ )}
+
+ );
+}
+
+function CompactEvidenceRow({ label, path, status }) {
+ return (
+
+
+
+ {status.label}
+
+
+
+
+ {label}
+
+
+ {formatShortPath(path)}
+
+
+
+ );
+}
+
+function MetricGrid({ evaluation }) {
+ const metrics = evaluation?.metrics || {};
+ const baseline = metrics.baseline || {};
+ const candidate = metrics.candidate || {};
+ const delta = metrics.delta || {};
+ const rows = METRIC_ROWS.filter(
+ (row) =>
+ baseline[row.key] !== undefined ||
+ candidate[row.key] !== undefined ||
+ delta[row.key] !== undefined,
+ );
+
+ if (!evaluation || rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {["Metric", "Previous", "New", "Change", "Goal"].map((label) => (
+
+ {label}
+
+ ))}
+ {rows.map((row) => {
+ const deltaValue = delta[row.key];
+ const improved =
+ typeof deltaValue === "number"
+ ? row.direction === "higher"
+ ? deltaValue >= 0
+ : deltaValue <= 0
+ : null;
+ return (
+
+ {row.label}
+
+ {formatMetric(baseline[row.key])}
+
+
+ {formatMetric(candidate[row.key])}
+
+
+ {formatMetric(deltaValue, { signed: true })}
+
+
+ {row.direction === "higher" ? "higher better" : "lower better"}
+
+
+ );
+ })}
+
+ );
+}
+
+function WorkflowEvidencePanel({ compact = false }) {
+ const workflowContext = useWorkflow();
+ const workflow = workflowContext?.workflow;
+ const events = workflowContext?.events || EMPTY_ARRAY;
+ const artifacts = workflowContext?.artifacts || EMPTY_ARRAY;
+ const modelRuns = workflowContext?.modelRuns || EMPTY_ARRAY;
+ const modelVersions = workflowContext?.modelVersions || EMPTY_ARRAY;
+ const correctionSets = workflowContext?.correctionSets || EMPTY_ARRAY;
+ const evaluationResults = workflowContext?.evaluationResults || EMPTY_ARRAY;
+ const refreshEvidence = workflowContext?.refreshEvidence;
+ const refreshEvents = workflowContext?.refreshEvents;
+ const [computingEvaluation, setComputingEvaluation] = useState(false);
+ const [exportingBundle, setExportingBundle] = useState(false);
+ const [showMetricOptions, setShowMetricOptions] = useState(false);
+ const [computeError, setComputeError] = useState("");
+ const [computeNotice, setComputeNotice] = useState("");
+ const [bundleNotice, setBundleNotice] = useState("");
+ const [evaluationInputs, setEvaluationInputs] = useState({
+ baselineDataset: "",
+ candidateDataset: "",
+ groundTruthDataset: "",
+ crop: "",
+ baselineChannel: "",
+ candidateChannel: "",
+ groundTruthChannel: "",
+ });
+
+ const evidence = useMemo(
+ () =>
+ summarizeEvidence({
+ workflow,
+ artifacts,
+ modelRuns,
+ correctionSets,
+ evaluationResults,
+ }),
+ [workflow, artifacts, modelRuns, correctionSets, evaluationResults],
+ );
+
+ if (!workflowContext || !workflow?.id) {
+ return (
+
+
+
+ );
+ }
+
+ const summary = evidence.latestEvaluation?.metrics?.summary || {};
+ const missingEvaluationInputs = getMissingEvaluationInputs(evidence);
+ const pipelineSteps = getPipelineSteps({
+ workflow,
+ artifacts,
+ modelRuns,
+ modelVersions,
+ correctionSets,
+ evaluationResults,
+ events,
+ evidence,
+ });
+ const canComputeEvaluation = missingEvaluationInputs.length === 0;
+ const improved = summary.candidate_improved_dice;
+ const statusTag =
+ improved === true
+ ? { label: "new result improved", color: "green" }
+ : improved === false
+ ? { label: "no Dice gain", color: "orange" }
+ : { label: "not compared yet", color: "default" };
+
+ const handleComputeEvaluation = async () => {
+ if (!canComputeEvaluation || computingEvaluation) return;
+ setComputingEvaluation(true);
+ setComputeError("");
+ setComputeNotice("");
+ try {
+ const result = await computeWorkflowEvaluationResult(
+ workflow.id,
+ compactObject({
+ name: "workflow-before-after-evaluation",
+ baseline_prediction_path: evidence.baselinePath,
+ candidate_prediction_path: evidence.candidatePath,
+ ground_truth_path: evidence.groundTruthPath,
+ baseline_run_id: evidence.baselineRun?.id,
+ candidate_run_id: evidence.candidateRun?.id,
+ metadata: {
+ source: "workflow_evidence_panel",
+ corrected_mask_path: evidence.correctedPath,
+ },
+ ...getEvaluationOptions(
+ workflow,
+ evidence.latestEvaluation,
+ evaluationInputs,
+ ),
+ }),
+ );
+ setComputeNotice(result?.summary || "Comparison computed.");
+ await refreshEvidence?.();
+ } catch (error) {
+ setComputeError(error.message || "Failed to compute evaluation metrics.");
+ } finally {
+ setComputingEvaluation(false);
+ }
+ };
+
+ const handleExportBundle = async () => {
+ if (exportingBundle) return;
+ setExportingBundle(true);
+ setComputeError("");
+ setBundleNotice("");
+ try {
+ const bundle = await exportWorkflowBundle(workflow.id);
+ const artifactPaths = bundle?.artifact_paths || [];
+ const missingCount = artifactPaths.filter(
+ (entry) => !entry.exists,
+ ).length;
+ setBundleNotice(
+ `Report exported: ${bundle?.artifacts?.length || 0} files, ${bundle?.model_runs?.length || 0} runs, ${bundle?.evaluation_results?.length || 0} comparisons, ${missingCount} missing paths.`,
+ );
+ await refreshEvents?.();
+ } catch (error) {
+ setComputeError(error.message || "Failed to export report bundle.");
+ } finally {
+ setExportingBundle(false);
+ }
+ };
+
+ if (compact) {
+ const completeEvidenceCount = [
+ evidence.baselinePath,
+ evidence.candidatePath,
+ evidence.correctedPath,
+ evidence.groundTruthPath,
+ ].filter(Boolean).length;
+
+ return (
+
+ Review status
+
+ {statusTag.label}
+
+
+ {completeEvidenceCount}/4 ready
+
+
+ }
+ extra={
+ refreshEvidence?.()}>
+ Refresh
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ Metrics
+
+ {summary.dice_delta !== undefined ? (
+ = 0 ? "green" : "orange"}>
+ Dice {formatMetric(summary.dice_delta, { signed: true })}
+
+ ) : (
+
+ Not computed
+
+ )}
+
+ {!canComputeEvaluation && (
+
+ Needed: {missingEvaluationInputs.join(", ")}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ Review status
+
+ {statusTag.label}
+
+
+ }
+ extra={
+
+
+ Compare results
+
+ refreshEvidence?.()}>
+ Refresh
+
+
+ Export report
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+ {!compact && }
+
+
+ {!compact && (
+
+ setShowMetricOptions((current) => !current)}
+ >
+ {showMetricOptions ? "Hide metric options" : "Metric options"}
+
+
+ )}
+ {!compact && showMetricOptions && (
+
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ baselineDataset: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ candidateDataset: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ groundTruthDataset: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ crop: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ baselineChannel: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ candidateChannel: event.target.value,
+ }))
+ }
+ />
+
+ setEvaluationInputs((current) => ({
+ ...current,
+ groundTruthChannel: event.target.value,
+ }))
+ }
+ />
+
+ )}
+
+ Improvement
+ {summary.dice_delta !== undefined && (
+ = 0 ? "green" : "orange"}>
+ Dice {formatMetric(summary.dice_delta, { signed: true })}
+
+ )}
+ {summary.iou_delta !== undefined && (
+ = 0 ? "green" : "orange"}>
+ IoU {formatMetric(summary.iou_delta, { signed: true })}
+
+ )}
+ {summary.voxel_accuracy_delta !== undefined && (
+ = 0 ? "green" : "orange"}
+ >
+ Accuracy{" "}
+ {formatMetric(summary.voxel_accuracy_delta, { signed: true })}
+
+ )}
+
+
+ {!canComputeEvaluation && (
+
+ Need {missingEvaluationInputs.join(", ")} before metrics can be
+ computed.
+
+ )}
+ {computeNotice && (
+
+ {computeNotice}
+
+ )}
+ {bundleNotice && (
+
+ {bundleNotice}
+
+ )}
+ {computeError && (
+
+ {computeError}
+
+ )}
+
+
+ {evidence.latestEvaluation ? (
+
+
+ Latest comparison
+
+
+ {evidence.latestEvaluation.summary ||
+ evidence.latestEvaluation.name ||
+ "Comparison recorded."}
+
+ {evidence.latestEvaluation.report_path && (
+
+ Report: {evidence.latestEvaluation.report_path}
+
+ )}
+
+ ) : (
+
+ No comparison yet. Register previous and new results, then compare
+ them.
+
+ )}
+
+
+ );
+}
+
+export default WorkflowEvidencePanel;
diff --git a/client/src/components/workflow/WorkflowEvidencePanel.test.js b/client/src/components/workflow/WorkflowEvidencePanel.test.js
new file mode 100644
index 00000000..06de192b
--- /dev/null
+++ b/client/src/components/workflow/WorkflowEvidencePanel.test.js
@@ -0,0 +1,318 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import WorkflowEvidencePanel from "./WorkflowEvidencePanel";
+import { WorkflowContext } from "../../contexts/WorkflowContext";
+import {
+ computeWorkflowEvaluationResult,
+ exportWorkflowBundle,
+} from "../../api";
+
+jest.mock("../../api", () => ({
+ computeWorkflowEvaluationResult: jest.fn(),
+ exportWorkflowBundle: jest.fn(),
+}));
+
+jest.mock("antd", () => {
+ const React = require("react");
+ const Card = ({ title, extra, children }) => (
+
+ );
+ const Empty = ({ description }) =>
{description}
;
+ Empty.PRESENTED_IMAGE_SIMPLE = "simple";
+ return {
+ Button: ({ children, loading, ...props }) => (
+
+ {children}
+
+ ),
+ Card,
+ Empty,
+ Input: ({ ...props }) =>
,
+ Space: ({ children }) =>
{children}
,
+ Tag: ({ children }) =>
{children} ,
+ Typography: {
+ Text: ({ children }) =>
{children} ,
+ },
+ };
+});
+
+jest.mock("../../contexts/WorkflowContext", () => {
+ const React = require("react");
+ const WorkflowContext = React.createContext(null);
+ return {
+ WorkflowContext,
+ useWorkflow: () => React.useContext(WorkflowContext),
+ };
+});
+
+function renderPanel(value = {}) {
+ const contextValue = {
+ workflow: {
+ id: 11,
+ stage: "evaluation",
+ image_path: "/tmp/image.tif",
+ corrected_mask_path: "/tmp/corrected-mask.tif",
+ },
+ events: [
+ {
+ id: 9,
+ event_type: "workflow.bundle_exported",
+ },
+ ],
+ artifacts: [
+ {
+ id: 1,
+ artifact_type: "inference_output",
+ role: "prediction",
+ path: "/tmp/baseline-prediction.tif",
+ exists: true,
+ },
+ {
+ id: 2,
+ artifact_type: "inference_output",
+ role: "prediction",
+ path: "/tmp/candidate-prediction.tif",
+ exists: true,
+ },
+ {
+ id: 3,
+ artifact_type: "correction_set",
+ role: "corrected_mask",
+ path: "/tmp/corrected-mask.tif",
+ exists: true,
+ },
+ ],
+ modelRuns: [
+ {
+ id: 4,
+ run_type: "inference",
+ status: "completed",
+ output_path: "/tmp/baseline-prediction.tif",
+ created_at: "2026-04-25T10:00:00Z",
+ },
+ {
+ id: 5,
+ run_type: "inference",
+ status: "completed",
+ output_path: "/tmp/candidate-prediction.tif",
+ created_at: "2026-04-25T10:10:00Z",
+ },
+ ],
+ modelVersions: [
+ {
+ id: 8,
+ version_label: "candidate",
+ status: "candidate",
+ },
+ ],
+ correctionSets: [
+ {
+ id: 6,
+ corrected_mask_path: "/tmp/corrected-mask.tif",
+ edit_count: 12,
+ region_count: 3,
+ created_at: "2026-04-25T10:05:00Z",
+ },
+ ],
+ evaluationResults: [
+ {
+ id: 7,
+ name: "before-after",
+ summary: "Before/after evaluation computed.",
+ report_path: "/tmp/evaluation-report.json",
+ created_at: "2026-04-25T10:15:00Z",
+ metadata: {
+ baseline_prediction_path: "/tmp/baseline-prediction.tif",
+ candidate_prediction_path: "/tmp/candidate-prediction.tif",
+ ground_truth_path: "/tmp/ground-truth.tif",
+ },
+ metrics: {
+ baseline: { dice: 0.5, iou: 0.4, voxel_accuracy: 0.8 },
+ candidate: { dice: 0.75, iou: 0.6, voxel_accuracy: 0.9 },
+ delta: { dice: 0.25, iou: 0.2, voxel_accuracy: 0.1 },
+ summary: {
+ dice_delta: 0.25,
+ iou_delta: 0.2,
+ voxel_accuracy_delta: 0.1,
+ candidate_improved_dice: true,
+ },
+ },
+ },
+ ],
+ refreshEvidence: jest.fn(),
+ refreshEvents: jest.fn(),
+ ...value,
+ };
+
+ render(
+
+
+ ,
+ );
+ return contextValue;
+}
+
+describe("WorkflowEvidencePanel", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ computeWorkflowEvaluationResult.mockResolvedValue({
+ summary: "Before/after evaluation computed. Dice delta: 0.25.",
+ });
+ exportWorkflowBundle.mockResolvedValue({
+ artifacts: [{ id: 1 }, { id: 2 }],
+ model_runs: [{ id: 3 }],
+ evaluation_results: [{ id: 4 }],
+ artifact_paths: [
+ { path: "/tmp/a.tif", exists: true },
+ { path: "/tmp/missing.tif", exists: false },
+ ],
+ });
+ });
+
+ it("renders baseline, candidate, corrected mask, and metric deltas", () => {
+ renderPanel();
+
+ expect(screen.getByText("Review status")).toBeTruthy();
+ expect(screen.getByText("new result improved")).toBeTruthy();
+ expect(screen.getByText("Previous result")).toBeTruthy();
+ expect(screen.getByText("/tmp/baseline-prediction.tif")).toBeTruthy();
+ expect(screen.getByText("New result")).toBeTruthy();
+ expect(screen.getByText("/tmp/candidate-prediction.tif")).toBeTruthy();
+ expect(screen.getByText("Your saved edits")).toBeTruthy();
+ expect(screen.getByText("/tmp/corrected-mask.tif")).toBeTruthy();
+ expect(screen.getByText("Dice +0.25")).toBeTruthy();
+ expect(screen.getByText("IoU +0.2")).toBeTruthy();
+ expect(screen.getByText("Accuracy +0.1")).toBeTruthy();
+ expect(screen.getByText("Before/after evaluation computed.")).toBeTruthy();
+ expect(
+ screen.getByText("Report: /tmp/evaluation-report.json"),
+ ).toBeTruthy();
+ expect(screen.getByText("Loop progress")).toBeTruthy();
+ expect(screen.getByText("ready")).toBeTruthy();
+ });
+
+ it("exposes a refresh action for current workflow evidence", () => {
+ const value = renderPanel();
+
+ fireEvent.click(screen.getByText("Refresh"));
+
+ expect(value.refreshEvidence).toHaveBeenCalledTimes(1);
+ });
+
+ it("computes before/after metrics from recorded evidence paths", async () => {
+ const value = renderPanel();
+
+ fireEvent.click(screen.getByText("Compare results"));
+
+ await waitFor(() => {
+ expect(computeWorkflowEvaluationResult).toHaveBeenCalledWith(
+ 11,
+ expect.objectContaining({
+ name: "workflow-before-after-evaluation",
+ baseline_prediction_path: "/tmp/baseline-prediction.tif",
+ candidate_prediction_path: "/tmp/candidate-prediction.tif",
+ ground_truth_path: "/tmp/ground-truth.tif",
+ baseline_run_id: 4,
+ candidate_run_id: 5,
+ metadata: expect.objectContaining({
+ source: "workflow_evidence_panel",
+ corrected_mask_path: "/tmp/corrected-mask.tif",
+ }),
+ }),
+ );
+ });
+ await waitFor(() => {
+ expect(value.refreshEvidence).toHaveBeenCalledTimes(1);
+ expect(
+ screen.getByText("Before/after evaluation computed. Dice delta: 0.25."),
+ ).toBeTruthy();
+ });
+ });
+
+ it("passes dataset, crop, and channel controls into metric computation", async () => {
+ renderPanel();
+
+ fireEvent.click(screen.getByText("Metric options"));
+ fireEvent.change(screen.getByLabelText("Baseline dataset key"), {
+ target: { value: "vol0" },
+ });
+ fireEvent.change(screen.getByLabelText("Candidate dataset key"), {
+ target: { value: "vol0" },
+ });
+ fireEvent.change(screen.getByLabelText("Ground truth dataset key"), {
+ target: { value: "data" },
+ });
+ fireEvent.change(screen.getByLabelText("Evaluation crop"), {
+ target: { value: "0:4,0:128,0:128" },
+ });
+ fireEvent.change(screen.getByLabelText("Baseline channel"), {
+ target: { value: "1" },
+ });
+ fireEvent.change(screen.getByLabelText("Candidate channel"), {
+ target: { value: "1" },
+ });
+
+ fireEvent.click(screen.getByText("Compare results"));
+
+ await waitFor(() => {
+ expect(computeWorkflowEvaluationResult).toHaveBeenCalledWith(
+ 11,
+ expect.objectContaining({
+ baseline_dataset: "vol0",
+ candidate_dataset: "vol0",
+ ground_truth_dataset: "data",
+ crop: "0:4,0:128,0:128",
+ baseline_channel: 1,
+ candidate_channel: 1,
+ }),
+ );
+ });
+ });
+
+ it("exports a workflow bundle and summarizes artifact existence", async () => {
+ const value = renderPanel();
+
+ fireEvent.click(screen.getByText("Export report"));
+
+ await waitFor(() => {
+ expect(exportWorkflowBundle).toHaveBeenCalledWith(11);
+ expect(
+ screen.getByText(
+ "Report exported: 2 files, 1 runs, 1 comparisons, 1 missing paths.",
+ ),
+ ).toBeTruthy();
+ });
+ expect(value.refreshEvents).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks metric computation until all required paths are recorded", () => {
+ renderPanel({
+ evaluationResults: [],
+ modelRuns: [],
+ artifacts: [],
+ workflow: { id: 11, stage: "evaluation" },
+ });
+
+ expect(screen.getByText("Compare results").disabled).toBe(true);
+ expect(
+ screen.getByText(/Need previous result, new result, reference mask/),
+ ).toBeTruthy();
+ });
+
+ it("shows an empty state without an active workflow", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("No active workflow yet")).toBeTruthy();
+ });
+});
diff --git a/client/src/configSchema.js b/client/src/configSchema.js
index 23e6ca86..b5db2f3e 100644
--- a/client/src/configSchema.js
+++ b/client/src/configSchema.js
@@ -110,6 +110,10 @@ function resolveSliderPath(configObj, type, key) {
return pickFirstExistingPath(configObj, candidates);
}
+export function getSliderPath(configObj, type, key) {
+ return resolveSliderPath(configObj, type, key);
+}
+
export function getSliderValue(configObj, type, key) {
const path = resolveSliderPath(configObj, type, key);
return path ? getPathValue(configObj, path) : undefined;
@@ -135,14 +139,26 @@ export function setTrainingOutputPath(configObj, outputPath) {
}
const checkpointsPath = joinPath(outputPath, "checkpoints");
if (hasPath(configObj, ["train", "monitor", "checkpoint", "dirpath"])) {
- setPathValue(configObj, ["train", "monitor", "checkpoint", "dirpath"], checkpointsPath);
+ setPathValue(
+ configObj,
+ ["train", "monitor", "checkpoint", "dirpath"],
+ checkpointsPath,
+ );
return;
}
if (hasPath(configObj, ["monitor", "checkpoint", "dirpath"])) {
- setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath);
+ setPathValue(
+ configObj,
+ ["monitor", "checkpoint", "dirpath"],
+ checkpointsPath,
+ );
return;
}
- setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath);
+ setPathValue(
+ configObj,
+ ["monitor", "checkpoint", "dirpath"],
+ checkpointsPath,
+ );
}
export function setInferenceOutputPath(configObj, outputPath) {
@@ -152,7 +168,11 @@ export function setInferenceOutputPath(configObj, outputPath) {
setPathValue(configObj, ["INFERENCE", "OUTPUT_PATH"], outputPath);
return;
}
- setPathValue(configObj, ["inference", "save_prediction", "output_path"], outputPath);
+ setPathValue(
+ configObj,
+ ["inference", "save_prediction", "output_path"],
+ outputPath,
+ );
}
export function setInferenceExecutionDefaults(configObj) {
@@ -192,6 +212,14 @@ export function applyInputPaths(
["DATASET", "IMAGE_NAME"],
inputImagePath.replace(inputPath, ""),
);
+ if (mode === "inference") {
+ setPathValue(configObj, ["INFERENCE", "INPUT_PATH"], inputPath || null);
+ setPathValue(
+ configObj,
+ ["INFERENCE", "IMAGE_NAME"],
+ inputImagePath.replace(inputPath, ""),
+ );
+ }
if (hasLabelPath) {
setPathValue(
configObj,
@@ -212,16 +240,14 @@ export function applyInputPaths(
}
if (mode === "training") {
- const imagePath =
- pickFirstExistingPath(configObj, [
- ["train", "data", "train", "image"],
- ["data", "train", "image"],
- ]) || ["train", "data", "train", "image"];
- const labelPath =
- pickFirstExistingPath(configObj, [
- ["train", "data", "train", "label"],
- ["data", "train", "label"],
- ]) || ["train", "data", "train", "label"];
+ const imagePath = pickFirstExistingPath(configObj, [
+ ["train", "data", "train", "image"],
+ ["data", "train", "image"],
+ ]) || ["train", "data", "train", "image"];
+ const labelPath = pickFirstExistingPath(configObj, [
+ ["train", "data", "train", "label"],
+ ["data", "train", "label"],
+ ]) || ["train", "data", "train", "label"];
setPathValue(configObj, imagePath, inputImagePath);
setPathValue(configObj, labelPath, inputLabelPath);
if (outputPath) {
@@ -230,16 +256,14 @@ export function applyInputPaths(
return;
}
- const imagePath =
- pickFirstExistingPath(configObj, [
- ["test", "data", "test", "image"],
- ["data", "test", "image"],
- ]) || ["test", "data", "test", "image"];
- const labelPath =
- pickFirstExistingPath(configObj, [
- ["test", "data", "test", "label"],
- ["data", "test", "label"],
- ]) || ["test", "data", "test", "label"];
+ const imagePath = pickFirstExistingPath(configObj, [
+ ["test", "data", "test", "image"],
+ ["data", "test", "image"],
+ ]) || ["test", "data", "test", "image"];
+ const labelPath = pickFirstExistingPath(configObj, [
+ ["test", "data", "test", "label"],
+ ["data", "test", "label"],
+ ]) || ["test", "data", "test", "label"];
setPathValue(configObj, imagePath, inputImagePath);
if (hasLabelPath) {
setPathValue(configObj, labelPath, inputLabelPath);
@@ -251,34 +275,27 @@ export function applyInputPaths(
}
}
-export function getArchitectureValue(configObj) {
- if (!isObject(configObj)) return undefined;
- const path = pickFirstExistingPath(configObj, [
+export function getArchitecturePath(configObj) {
+ if (!isObject(configObj)) return null;
+ return pickFirstExistingPath(configObj, [
["MODEL", "ARCHITECTURE"],
["model", "arch", "type"],
["default", "model", "arch", "profile"],
]);
+}
+
+export function getArchitectureValue(configObj) {
+ const path = getArchitecturePath(configObj);
return path ? getPathValue(configObj, path) : undefined;
}
export function isArchitectureSupported(configObj) {
- if (!isObject(configObj)) return false;
- return Boolean(
- pickFirstExistingPath(configObj, [
- ["MODEL", "ARCHITECTURE"],
- ["model", "arch", "type"],
- ["default", "model", "arch", "profile"],
- ]),
- );
+ return Boolean(getArchitecturePath(configObj));
}
export function setArchitectureValue(configObj, value) {
if (!isObject(configObj)) return false;
- const existingPath = pickFirstExistingPath(configObj, [
- ["MODEL", "ARCHITECTURE"],
- ["model", "arch", "type"],
- ["default", "model", "arch", "profile"],
- ]);
+ const existingPath = getArchitecturePath(configObj);
if (existingPath) {
setPathValue(configObj, existingPath, value);
return true;
diff --git a/client/src/contexts/GlobalContext.js b/client/src/contexts/GlobalContext.js
index 9eb4ecad..43297b93 100644
--- a/client/src/contexts/GlobalContext.js
+++ b/client/src/contexts/GlobalContext.js
@@ -1,4 +1,4 @@
-import React, { createContext, useState, useEffect, useCallback } from "react";
+import React, { createContext, useState, useCallback, useEffect } from "react";
import localforage from "localforage";
export const AppContext = createContext(null);
@@ -10,99 +10,71 @@ const FILE_STATE_DEFAULTS = {
labelFileList: [],
currentImage: null,
currentLabel: null,
+ trainingConfig: null,
+ inferenceConfig: null,
+ trainingConfigOriginPath: "",
+ inferenceConfigOriginPath: "",
+ trainingUploadedYamlFile: "",
+ inferenceUploadedYamlFile: "",
+ trainingSelectedYamlPreset: "",
+ inferenceSelectedYamlPreset: "",
+ trainingOutputPath: null,
+ inferenceOutputPath: null,
+ trainingLogPath: null,
+ inferenceCheckpointPath: null,
trainingInputImage: null,
trainingInputLabel: null,
inferenceInputImage: null,
inferenceInputLabel: null,
+ viewer: null,
+ tensorBoardURL: null,
+ visualizationScales: "",
};
const FILE_CACHE_KEYS = Object.keys(FILE_STATE_DEFAULTS);
-const FILE_OBJECT_STATE_KEYS = [
- ...FILE_CACHE_KEYS.filter((key) => key !== "fileList"),
- "trainingUploadedYamlFile",
- "inferenceUploadedYamlFile",
-];
+const NON_PERSISTED_FILE_STATE_KEYS = new Set(["viewer"]);
-const sanitizeFileEntry = (file) => {
- if (!file || typeof file !== "object") return file;
- const {
- uid,
- name,
- originalName,
- path,
- folderPath,
- thumbUrl,
- type,
- status,
- percent,
- size,
- response,
- error,
- lastModified,
- lastModifiedDate,
- url,
- } = file;
- return {
- uid,
- name,
- originalName: originalName || name,
- path,
- folderPath,
- thumbUrl,
- type,
- status,
- percent,
- size,
- response,
- error,
- lastModified,
- lastModifiedDate,
- url,
- };
-};
-
-const sanitizePersistedState = (key, state) => {
- if (!FILE_OBJECT_STATE_KEYS.includes(key)) {
- return state;
- }
- if (Array.isArray(state)) {
- return state.map((entry) => sanitizeFileEntry(entry));
- }
- return sanitizeFileEntry(state);
-};
-
-// Solve delete button error issue
function usePersistedState(key, defaultValue) {
const [state, setState] = useState(defaultValue);
- const [isLoaded, setIsLoaded] = useState(false);
+ const shouldPersist = !NON_PERSISTED_FILE_STATE_KEYS.has(key);
useEffect(() => {
- // Fetch the stored value asynchronously when the component mounts
+ if (!shouldPersist) return undefined;
+ let cancelled = false;
localforage
.getItem(key)
.then((storedValue) => {
- if (storedValue !== null) {
+ if (!cancelled && storedValue !== null && storedValue !== undefined) {
setState(storedValue);
}
- setIsLoaded(true);
})
- .catch((err) => {
- console.error("Error retrieving value from localforage:", err);
- setIsLoaded(true);
+ .catch((error) => {
+ console.warn(`Failed to hydrate cached state for ${key}:`, error);
});
- }, [key]);
+ return () => {
+ cancelled = true;
+ };
+ }, [key, shouldPersist]);
- useEffect(() => {
- if (isLoaded) {
- // Save the state to localforage asynchronously whenever it changes
- const valueToPersist = sanitizePersistedState(key, state);
- localforage.setItem(key, valueToPersist).catch((err) => {
- console.error("Error setting value to localforage:", err);
+ const setPersistedState = useCallback(
+ (nextValueOrUpdater) => {
+ setState((previousValue) => {
+ const nextValue =
+ typeof nextValueOrUpdater === "function"
+ ? nextValueOrUpdater(previousValue)
+ : nextValueOrUpdater;
+ if (shouldPersist) {
+ localforage.setItem(key, nextValue).catch((error) => {
+ console.warn(`Failed to persist cached state for ${key}:`, error);
+ });
+ }
+ return nextValue;
});
- }
- }, [key, state, isLoaded]);
+ },
+ [key, shouldPersist],
+ );
- return [state, setState];
+ return [state, setPersistedState];
}
// function safeParseJSON(jsonString, defaultValue) {
@@ -130,25 +102,13 @@ export const ContextWrapper = (props) => {
const [inferenceConfigOriginPath, setInferenceConfigOriginPath] =
usePersistedState("inferenceConfigOriginPath", "");
const [trainingUploadedYamlFile, setTrainingUploadedYamlFile] =
- usePersistedState(
- "trainingUploadedYamlFile",
- "",
- );
+ usePersistedState("trainingUploadedYamlFile", "");
const [inferenceUploadedYamlFile, setInferenceUploadedYamlFile] =
- usePersistedState(
- "inferenceUploadedYamlFile",
- "",
- );
+ usePersistedState("inferenceUploadedYamlFile", "");
const [trainingSelectedYamlPreset, setTrainingSelectedYamlPreset] =
- usePersistedState(
- "trainingSelectedYamlPreset",
- "",
- );
+ usePersistedState("trainingSelectedYamlPreset", "");
const [inferenceSelectedYamlPreset, setInferenceSelectedYamlPreset] =
- usePersistedState(
- "inferenceSelectedYamlPreset",
- "",
- );
+ usePersistedState("inferenceSelectedYamlPreset", "");
const [imageFileList, setImageFileList] = usePersistedState(
"imageFileList",
[],
@@ -170,10 +130,7 @@ export const ContextWrapper = (props) => {
null,
);
const [inferenceCheckpointPath, setInferenceCheckpointPath] =
- usePersistedState(
- "inferenceCheckpointPath",
- null,
- );
+ usePersistedState("inferenceCheckpointPath", null);
const [currentImage, setCurrentImage] = usePersistedState(
"currentImage",
null,
@@ -182,6 +139,10 @@ export const ContextWrapper = (props) => {
"currentLabel",
null,
);
+ const [visualizationScales, setVisualizationScales] = usePersistedState(
+ "visualizationScales",
+ FILE_STATE_DEFAULTS.visualizationScales,
+ );
const [trainingInputImage, setTrainingInputImage] = usePersistedState(
"trainingInputImage",
null,
@@ -216,24 +177,62 @@ export const ContextWrapper = (props) => {
setFileList(FILE_STATE_DEFAULTS.fileList);
setImageFileList(FILE_STATE_DEFAULTS.imageFileList);
setLabelFileList(FILE_STATE_DEFAULTS.labelFileList);
+ setTrainingConfig(FILE_STATE_DEFAULTS.trainingConfig);
+ setInferenceConfig(FILE_STATE_DEFAULTS.inferenceConfig);
+ setTrainingConfigOriginPath(FILE_STATE_DEFAULTS.trainingConfigOriginPath);
+ setInferenceConfigOriginPath(
+ FILE_STATE_DEFAULTS.inferenceConfigOriginPath,
+ );
+ setTrainingUploadedYamlFile(FILE_STATE_DEFAULTS.trainingUploadedYamlFile);
+ setInferenceUploadedYamlFile(
+ FILE_STATE_DEFAULTS.inferenceUploadedYamlFile,
+ );
+ setTrainingSelectedYamlPreset(
+ FILE_STATE_DEFAULTS.trainingSelectedYamlPreset,
+ );
+ setInferenceSelectedYamlPreset(
+ FILE_STATE_DEFAULTS.inferenceSelectedYamlPreset,
+ );
+ setTrainingOutputPath(FILE_STATE_DEFAULTS.trainingOutputPath);
+ setInferenceOutputPath(FILE_STATE_DEFAULTS.inferenceOutputPath);
+ setTrainingLogPath(FILE_STATE_DEFAULTS.trainingLogPath);
+ setInferenceCheckpointPath(FILE_STATE_DEFAULTS.inferenceCheckpointPath);
setCurrentImage(FILE_STATE_DEFAULTS.currentImage);
setCurrentLabel(FILE_STATE_DEFAULTS.currentLabel);
+ setVisualizationScales(FILE_STATE_DEFAULTS.visualizationScales);
setTrainingInputImage(FILE_STATE_DEFAULTS.trainingInputImage);
setTrainingInputLabel(FILE_STATE_DEFAULTS.trainingInputLabel);
setInferenceInputImage(FILE_STATE_DEFAULTS.inferenceInputImage);
setInferenceInputLabel(FILE_STATE_DEFAULTS.inferenceInputLabel);
+ setViewer(FILE_STATE_DEFAULTS.viewer);
+ setTensorBoardURL(FILE_STATE_DEFAULTS.tensorBoardURL);
}
}, [
setFiles,
setFileList,
setImageFileList,
setLabelFileList,
+ setTrainingConfig,
+ setInferenceConfig,
+ setTrainingConfigOriginPath,
+ setInferenceConfigOriginPath,
+ setTrainingUploadedYamlFile,
+ setInferenceUploadedYamlFile,
+ setTrainingSelectedYamlPreset,
+ setInferenceSelectedYamlPreset,
+ setTrainingOutputPath,
+ setInferenceOutputPath,
+ setTrainingLogPath,
+ setInferenceCheckpointPath,
setCurrentImage,
setCurrentLabel,
+ setVisualizationScales,
setTrainingInputImage,
setTrainingInputLabel,
setInferenceInputImage,
setInferenceInputLabel,
+ setViewer,
+ setTensorBoardURL,
]);
const trainingState = {
@@ -281,6 +280,8 @@ export const ContextWrapper = (props) => {
setCurrentImage,
currentLabel,
setCurrentLabel,
+ visualizationScales,
+ setVisualizationScales,
viewer,
setViewer,
trainingConfig,
diff --git a/client/src/contexts/WorkflowContext.js b/client/src/contexts/WorkflowContext.js
index fc9f23d1..0a651299 100644
--- a/client/src/contexts/WorkflowContext.js
+++ b/client/src/contexts/WorkflowContext.js
@@ -3,37 +3,188 @@ import React, {
useCallback,
useContext,
useEffect,
+ useRef,
useState,
} from "react";
import { message } from "antd";
import {
appendWorkflowEvent,
approveAgentAction as approveAgentActionApi,
+ computeWorkflowEvaluationResult,
createAgentAction,
+ exportWorkflowBundle,
+ getConfigPresetContent,
getCurrentWorkflow,
+ getWorkflowAgentRecommendation,
getWorkflowHotspots,
getWorkflowImpactPreview,
+ getWorkflowOverview,
+ getWorkflowPreflight,
+ getWorkflowProjectProgress,
+ listWorkflowArtifacts,
+ listWorkflowCorrectionSets,
listWorkflowEvents,
+ listWorkflowEvaluationResults,
+ listWorkflowModelRuns,
+ listWorkflowModelVersions,
+ mountProjectDirectory,
queryWorkflowAgent,
rejectAgentAction as rejectAgentActionApi,
+ resetFileWorkspace,
+ runWorkflowCommand,
+ startNewWorkflow as startNewWorkflowApi,
+ stopModelInference,
+ stopModelTraining,
updateWorkflow as updateWorkflowApi,
+ updateWorkflowProjectProgressVolume,
} from "../api";
import { AppContext } from "./GlobalContext";
+import { logClientEvent } from "../logging/appEventLog";
export const WorkflowContext = createContext(null);
+const PENDING_RUNTIME_ACTION_KEY = "pytc.workflow.pendingRuntimeAction.v1";
+const PENDING_RUNTIME_ACTION_TTL_MS = 6 * 60 * 60 * 1000;
+const PERSISTABLE_RUNTIME_KINDS = new Set(["monitor_training"]);
+
+const isPersistableRuntimeAction = (kind) =>
+ PERSISTABLE_RUNTIME_KINDS.has(kind);
+
+const readPersistedRuntimeAction = (workflowId = null) => {
+ if (typeof window === "undefined" || !window.sessionStorage) return null;
+ if (!workflowId) return null;
+
+ try {
+ const raw = window.sessionStorage.getItem(PENDING_RUNTIME_ACTION_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (
+ parsed?.kind !== "pending_runtime_action" ||
+ parsed?.workflowId !== workflowId ||
+ !parsed?.action?.kind
+ ) {
+ if (parsed?.kind || parsed?.action) {
+ window.sessionStorage.removeItem(PENDING_RUNTIME_ACTION_KEY);
+ }
+ return null;
+ }
+ const savedAt = Number(parsed.savedAt || 0);
+ const age = Date.now() - savedAt;
+ if (!Number.isFinite(age) || age > PENDING_RUNTIME_ACTION_TTL_MS) {
+ window.sessionStorage.removeItem(PENDING_RUNTIME_ACTION_KEY);
+ return null;
+ }
+ return parsed.action;
+ } catch {
+ return null;
+ }
+};
+
+const writePersistedRuntimeAction = (workflowId = null, action = null) => {
+ if (typeof window === "undefined" || !window.sessionStorage) return;
+ if (!workflowId || !action) {
+ window.sessionStorage.removeItem(PENDING_RUNTIME_ACTION_KEY);
+ return;
+ }
+ try {
+ window.sessionStorage.setItem(
+ PENDING_RUNTIME_ACTION_KEY,
+ JSON.stringify({
+ kind: "pending_runtime_action",
+ workflowId,
+ action,
+ savedAt: Date.now(),
+ }),
+ );
+ } catch {
+ // Persisting pending runtime actions is best-effort; it should not block runtime.
+ }
+};
+
export function useWorkflow() {
return useContext(WorkflowContext);
}
+const buildRuntimeOverridesFromEffects = (
+ effects = {},
+ {
+ resolvedTrainingConfig = "",
+ resolvedTrainingConfigOrigin = "",
+ resolvedInferenceConfig = "",
+ resolvedInferenceConfigOrigin = "",
+ } = {},
+) => ({
+ inputLabelPath:
+ effects.set_training_label_path || effects.set_inference_label_path || "",
+ inputImagePath:
+ effects.set_training_image_path || effects.set_inference_image_path || "",
+ logPath: effects.set_training_log_path || "",
+ outputPath:
+ effects.set_training_output_path || effects.set_inference_output_path || "",
+ trainingConfig: resolvedTrainingConfig || undefined,
+ configOriginPath: resolvedTrainingConfigOrigin || undefined,
+ autoParameters: Boolean(effects.runtime_action?.autopick_parameters),
+ checkpointPath: effects.set_inference_checkpoint_path || "",
+ inferenceConfig: resolvedInferenceConfig || undefined,
+ inferenceConfigOriginPath: resolvedInferenceConfigOrigin || undefined,
+ inferenceInputImagePath: effects.set_inference_image_path || "",
+ inferenceInputLabelPath: effects.set_inference_label_path || "",
+ datasetPath: effects.set_proofreading_dataset_path || "",
+ maskPath: effects.set_proofreading_mask_path || "",
+ projectName: effects.set_proofreading_project_name || "",
+ visualizationImagePath: effects.set_visualization_image_path || "",
+ visualizationLabelPath: effects.set_visualization_label_path || "",
+ visualizationScales: effects.set_visualization_scales || "",
+});
+
export function WorkflowProvider({ children }) {
const appContext = useContext(AppContext);
const [workflow, setWorkflow] = useState(null);
const [events, setEvents] = useState([]);
const [hotspots, setHotspots] = useState([]);
const [impactPreview, setImpactPreview] = useState(null);
+ const [agentRecommendation, setAgentRecommendation] = useState(null);
+ const [preflight, setPreflight] = useState(null);
+ const [workflowOverview, setWorkflowOverview] = useState(null);
+ const [projectProgress, setProjectProgress] = useState(null);
+ const [artifacts, setArtifacts] = useState([]);
+ const [modelRuns, setModelRuns] = useState([]);
+ const [modelVersions, setModelVersions] = useState([]);
+ const [correctionSets, setCorrectionSets] = useState([]);
+ const [evaluationResults, setEvaluationResults] = useState([]);
const [loading, setLoading] = useState(true);
const [lastClientEffects, setLastClientEffects] = useState(null);
+ const [pendingRuntimeAction, setPendingRuntimeAction] = useState(null);
+ const hydratedPendingRuntimeRef = useRef(false);
+
+ const clearPersistedRuntimeAction = useCallback(() => {
+ writePersistedRuntimeAction(null, null);
+ }, []);
+
+ const registerPendingRuntimeAction = useCallback(
+ (action, persist = false) => {
+ if (!action) return;
+ const nextAction = {
+ id: action.id || `${action.kind}:${Date.now()}`,
+ ...action,
+ };
+ setPendingRuntimeAction(nextAction);
+ const shouldPersist = persist || isPersistableRuntimeAction(action.kind);
+ if (shouldPersist) {
+ writePersistedRuntimeAction(workflow?.id, nextAction);
+ } else if (workflow?.id) {
+ writePersistedRuntimeAction(workflow?.id, null);
+ }
+ return nextAction;
+ },
+ [workflow?.id],
+ );
+
+ const clientEffectsWithoutRuntime = useCallback((effects) => {
+ if (!effects || typeof effects !== "object") return effects;
+ const { runtime_action: _runtimeAction, ...rest } = effects;
+ return rest;
+ }, []);
const refreshWorkflow = useCallback(async () => {
setLoading(true);
@@ -50,6 +201,24 @@ export function WorkflowProvider({ children }) {
}
}, []);
+ const applyWorkflowDetail = useCallback((data) => {
+ setWorkflow(data?.workflow || null);
+ setEvents(data?.events || []);
+ setHotspots([]);
+ setImpactPreview(null);
+ setAgentRecommendation(null);
+ setPreflight(null);
+ setWorkflowOverview(null);
+ setProjectProgress(null);
+ setArtifacts([]);
+ setModelRuns([]);
+ setModelVersions([]);
+ setCorrectionSets([]);
+ setEvaluationResults([]);
+ setLastClientEffects(null);
+ setPendingRuntimeAction(null);
+ }, []);
+
const refreshEvents = useCallback(async () => {
if (!workflow?.id) return [];
const nextEvents = await listWorkflowEvents(workflow.id);
@@ -79,9 +248,126 @@ export function WorkflowProvider({ children }) {
}
}, [workflow?.id]);
- useEffect(() => {
- refreshWorkflow();
- }, [refreshWorkflow]);
+ const refreshAgentRecommendation = useCallback(async () => {
+ if (!workflow?.id) {
+ setAgentRecommendation(null);
+ return null;
+ }
+ try {
+ const recommendation = await getWorkflowAgentRecommendation(workflow.id);
+ setAgentRecommendation(recommendation || null);
+ return recommendation || null;
+ } catch (error) {
+ console.warn("Workflow agent recommendation refresh failed:", error);
+ return null;
+ }
+ }, [workflow?.id]);
+
+ const refreshPreflight = useCallback(async () => {
+ if (!workflow?.id) {
+ setPreflight(null);
+ return null;
+ }
+ try {
+ const nextPreflight = await getWorkflowPreflight(workflow.id);
+ setPreflight(nextPreflight || null);
+ return nextPreflight || null;
+ } catch (error) {
+ console.warn("Workflow preflight refresh failed:", error);
+ return null;
+ }
+ }, [workflow?.id]);
+
+ const refreshProjectProgress = useCallback(async () => {
+ if (!workflow?.id) {
+ setProjectProgress(null);
+ return null;
+ }
+ try {
+ const progress = await getWorkflowProjectProgress(workflow.id);
+ setProjectProgress(progress || null);
+ return progress || null;
+ } catch (error) {
+ console.warn("Workflow project progress refresh failed:", error);
+ return null;
+ }
+ }, [workflow?.id]);
+
+ const refreshWorkflowOverview = useCallback(
+ async ({ refresh = true } = {}) => {
+ if (!workflow?.id) {
+ setWorkflowOverview(null);
+ return null;
+ }
+ try {
+ const overview = await getWorkflowOverview(workflow.id, { refresh });
+ setWorkflowOverview(overview || null);
+ if (overview?.project_progress) {
+ setProjectProgress(overview.project_progress);
+ }
+ return overview || null;
+ } catch (error) {
+ console.warn("Workflow overview refresh failed:", error);
+ return null;
+ }
+ },
+ [workflow?.id],
+ );
+
+ const updateProjectProgressVolume = useCallback(
+ async (body) => {
+ if (!workflow?.id) return null;
+ const progress = await updateWorkflowProjectProgressVolume(
+ workflow.id,
+ body,
+ );
+ setProjectProgress(progress || null);
+ await refreshWorkflowOverview({ refresh: false });
+ return progress || null;
+ },
+ [refreshWorkflowOverview, workflow?.id],
+ );
+
+ const refreshEvidence = useCallback(async () => {
+ if (!workflow?.id) {
+ setArtifacts([]);
+ setModelRuns([]);
+ setModelVersions([]);
+ setCorrectionSets([]);
+ setEvaluationResults([]);
+ return null;
+ }
+ try {
+ const [
+ nextArtifacts,
+ nextModelRuns,
+ nextModelVersions,
+ nextCorrectionSets,
+ nextEvaluationResults,
+ ] = await Promise.all([
+ listWorkflowArtifacts(workflow.id),
+ listWorkflowModelRuns(workflow.id),
+ listWorkflowModelVersions(workflow.id),
+ listWorkflowCorrectionSets(workflow.id),
+ listWorkflowEvaluationResults(workflow.id),
+ ]);
+ setArtifacts(nextArtifacts || []);
+ setModelRuns(nextModelRuns || []);
+ setModelVersions(nextModelVersions || []);
+ setCorrectionSets(nextCorrectionSets || []);
+ setEvaluationResults(nextEvaluationResults || []);
+ return {
+ artifacts: nextArtifacts || [],
+ modelRuns: nextModelRuns || [],
+ modelVersions: nextModelVersions || [],
+ correctionSets: nextCorrectionSets || [],
+ evaluationResults: nextEvaluationResults || [],
+ };
+ } catch (error) {
+ console.warn("Workflow evidence refresh failed:", error);
+ return null;
+ }
+ }, [workflow?.id]);
useEffect(() => {
if (!workflow?.id) {
@@ -98,6 +384,65 @@ export function WorkflowProvider({ children }) {
refreshInsights,
]);
+ useEffect(() => {
+ refreshEvidence();
+ }, [
+ workflow?.id,
+ workflow?.stage,
+ workflow?.inference_output_path,
+ workflow?.corrected_mask_path,
+ workflow?.training_output_path,
+ events.length,
+ refreshEvidence,
+ ]);
+
+ useEffect(() => {
+ refreshAgentRecommendation();
+ }, [
+ workflow?.id,
+ workflow?.stage,
+ workflow?.inference_output_path,
+ workflow?.corrected_mask_path,
+ workflow?.training_output_path,
+ events.length,
+ refreshAgentRecommendation,
+ ]);
+
+ useEffect(() => {
+ refreshPreflight();
+ }, [
+ workflow?.id,
+ workflow?.stage,
+ workflow?.dataset_path,
+ workflow?.image_path,
+ workflow?.label_path,
+ workflow?.mask_path,
+ workflow?.inference_output_path,
+ workflow?.checkpoint_path,
+ workflow?.config_path,
+ workflow?.corrected_mask_path,
+ workflow?.training_output_path,
+ events.length,
+ refreshPreflight,
+ ]);
+
+ useEffect(() => {
+ refreshWorkflowOverview();
+ }, [
+ workflow?.id,
+ workflow?.stage,
+ workflow?.dataset_path,
+ workflow?.image_path,
+ workflow?.label_path,
+ workflow?.mask_path,
+ workflow?.inference_output_path,
+ workflow?.checkpoint_path,
+ workflow?.corrected_mask_path,
+ workflow?.training_output_path,
+ events.length,
+ refreshWorkflowOverview,
+ ]);
+
const updateWorkflow = useCallback(
async (patch) => {
if (!workflow?.id) return null;
@@ -111,13 +456,99 @@ export function WorkflowProvider({ children }) {
const appendEvent = useCallback(
async (event) => {
if (!workflow?.id) return null;
- const nextEvent = await appendWorkflowEvent(workflow.id, event);
- setEvents((prev) => [...prev, nextEvent]);
- return nextEvent;
+ try {
+ const nextEvent = await appendWorkflowEvent(workflow.id, event);
+ if (nextEvent) {
+ setEvents((prev) => [...prev, nextEvent]);
+ }
+ return nextEvent;
+ } catch (error) {
+ console.warn("Workflow event append failed:", error);
+ return null;
+ }
},
[workflow?.id],
);
+ const clearLocalWorkflowInputs = useCallback(async () => {
+ await appContext?.resetFileState?.();
+ appContext?.setTrainingConfig?.(null);
+ appContext?.setInferenceConfig?.(null);
+ appContext?.setViewer?.(null);
+ appContext?.setTensorBoardURL?.(null);
+ appContext?.trainingState?.setConfigOriginPath?.("");
+ appContext?.trainingState?.setUploadedYamlFile?.("");
+ appContext?.trainingState?.setSelectedYamlPreset?.("");
+ appContext?.trainingState?.setOutputPath?.(null);
+ appContext?.trainingState?.setLogPath?.(null);
+ appContext?.inferenceState?.setConfigOriginPath?.("");
+ appContext?.inferenceState?.setUploadedYamlFile?.("");
+ appContext?.inferenceState?.setSelectedYamlPreset?.("");
+ appContext?.inferenceState?.setOutputPath?.(null);
+ appContext?.inferenceState?.setCheckpointPath?.(null);
+ }, [appContext]);
+
+ const startNewWorkflow = useCallback(
+ async (body = {}) => {
+ await resetFileWorkspace();
+ await clearLocalWorkflowInputs();
+ const data = await startNewWorkflowApi(body);
+ applyWorkflowDetail(data);
+ return data;
+ },
+ [applyWorkflowDetail, clearLocalWorkflowInputs],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ const resumeWorkflowSession = async () => {
+ setLoading(true);
+ try {
+ const data = await getCurrentWorkflow();
+ if (cancelled) return;
+ applyWorkflowDetail(data);
+
+ const mountedProjectPath = data?.workflow?.dataset_path;
+ if (mountedProjectPath) {
+ try {
+ await mountProjectDirectory({
+ directoryPath: mountedProjectPath,
+ mountName: data?.workflow?.title || "",
+ destinationPath: "root",
+ });
+ } catch (mountError) {
+ console.warn("Initial project remount failed:", mountError);
+ }
+ }
+ } catch (error) {
+ console.warn("Workflow session resume failed:", error);
+ if (!cancelled) {
+ message.error("Failed to load workflow session.");
+ }
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ resumeWorkflowSession();
+ return () => {
+ cancelled = true;
+ };
+ }, [applyWorkflowDetail]);
+
+ useEffect(() => {
+ if (!workflow?.id) return;
+ const workflowId = workflow.id;
+ if (hydratedPendingRuntimeRef.current === workflowId) return;
+ hydratedPendingRuntimeRef.current = workflowId;
+
+ const restored = readPersistedRuntimeAction(workflowId);
+ if (restored) {
+ setPendingRuntimeAction(restored);
+ } else {
+ clearPersistedRuntimeAction();
+ }
+ }, [clearPersistedRuntimeAction, workflow?.id]);
+
const proposeAgentAction = useCallback(
async (action) => {
if (!workflow?.id) return null;
@@ -128,30 +559,417 @@ export function WorkflowProvider({ children }) {
[workflow?.id, refreshEvents],
);
- const applyClientEffects = useCallback(
- (effects) => {
+ const runClientEffects = useCallback(
+ async (effects) => {
if (!effects) return;
if (effects.set_training_label_path && appContext?.trainingState) {
appContext.trainingState.setInputLabel(effects.set_training_label_path);
}
- setLastClientEffects(effects);
+ if (effects.set_training_image_path && appContext?.trainingState) {
+ appContext.trainingState.setInputImage?.(
+ effects.set_training_image_path,
+ );
+ }
+ if (effects.set_training_log_path && appContext?.trainingState) {
+ appContext.trainingState.setLogPath(effects.set_training_log_path);
+ }
+ if (effects.set_training_output_path && appContext?.trainingState) {
+ appContext.trainingState.setOutputPath(
+ effects.set_training_output_path,
+ );
+ }
+ let resolvedTrainingConfig = "";
+ let resolvedTrainingConfigOrigin = "";
+ if (effects.set_training_config_preset && appContext?.trainingState) {
+ try {
+ const preset = await getConfigPresetContent(
+ effects.set_training_config_preset,
+ );
+ resolvedTrainingConfig = preset?.content || "";
+ resolvedTrainingConfigOrigin =
+ preset?.path || effects.set_training_config_preset;
+ if (resolvedTrainingConfig) {
+ appContext.setTrainingConfig?.(resolvedTrainingConfig);
+ appContext.trainingState.setConfigOriginPath?.(
+ resolvedTrainingConfigOrigin,
+ );
+ appContext.trainingState.setSelectedYamlPreset?.(
+ resolvedTrainingConfigOrigin,
+ );
+ appContext.trainingState.setUploadedYamlFile?.("");
+ }
+ } catch (error) {
+ logClientEvent("workflow_action_training_preset_load_failed", {
+ level: "ERROR",
+ source: "workflow_context",
+ message: error.message || "Training preset load failed",
+ data: {
+ workflowId: workflow?.id || null,
+ preset: effects.set_training_config_preset,
+ },
+ });
+ throw error;
+ }
+ }
+ if (effects.set_inference_checkpoint_path && appContext?.inferenceState) {
+ appContext.inferenceState.setCheckpointPath(
+ effects.set_inference_checkpoint_path,
+ );
+ }
+ if (effects.set_inference_image_path && appContext?.inferenceState) {
+ appContext.inferenceState.setInputImage?.(
+ effects.set_inference_image_path,
+ );
+ }
+ if (effects.set_inference_label_path && appContext?.inferenceState) {
+ appContext.inferenceState.setInputLabel?.(
+ effects.set_inference_label_path,
+ );
+ }
+ if (effects.set_inference_output_path && appContext?.inferenceState) {
+ appContext.inferenceState.setOutputPath(
+ effects.set_inference_output_path,
+ );
+ }
+ let resolvedInferenceConfig = "";
+ let resolvedInferenceConfigOrigin = "";
+ if (effects.set_inference_config_preset && appContext?.inferenceState) {
+ try {
+ const preset = await getConfigPresetContent(
+ effects.set_inference_config_preset,
+ );
+ resolvedInferenceConfig = preset?.content || "";
+ resolvedInferenceConfigOrigin =
+ preset?.path || effects.set_inference_config_preset;
+ if (resolvedInferenceConfig) {
+ appContext.setInferenceConfig?.(resolvedInferenceConfig);
+ appContext.inferenceState.setConfigOriginPath?.(
+ resolvedInferenceConfigOrigin,
+ );
+ appContext.inferenceState.setSelectedYamlPreset?.(
+ resolvedInferenceConfigOrigin,
+ );
+ appContext.inferenceState.setUploadedYamlFile?.("");
+ }
+ } catch (error) {
+ logClientEvent("workflow_action_inference_preset_load_failed", {
+ level: "ERROR",
+ source: "workflow_context",
+ message: error.message || "Inference preset load failed",
+ data: {
+ workflowId: workflow?.id || null,
+ preset: effects.set_inference_config_preset,
+ },
+ });
+ throw error;
+ }
+ }
+ if (effects.set_visualization_image_path && appContext?.setCurrentImage) {
+ appContext.setCurrentImage(effects.set_visualization_image_path);
+ }
+ if (effects.set_visualization_label_path && appContext?.setCurrentLabel) {
+ appContext.setCurrentLabel(effects.set_visualization_label_path);
+ }
+ if (
+ effects.set_visualization_scales &&
+ appContext?.setVisualizationScales
+ ) {
+ const nextScales = Array.isArray(effects.set_visualization_scales)
+ ? effects.set_visualization_scales.join(",")
+ : String(effects.set_visualization_scales);
+ appContext.setVisualizationScales(nextScales);
+ }
+ if (effects.runtime_action?.kind) {
+ if (effects.runtime_action.kind === "stop_inference") {
+ await stopModelInference();
+ message.info("Inference stop requested.");
+ } else if (effects.runtime_action.kind === "stop_training") {
+ await stopModelTraining();
+ message.info("Training stop requested.");
+ }
+ const nextRuntimeAction = {
+ id: `${effects.runtime_action.kind}:${Date.now()}`,
+ ...effects.runtime_action,
+ overrides: buildRuntimeOverridesFromEffects(effects, {
+ resolvedTrainingConfig,
+ resolvedTrainingConfigOrigin,
+ resolvedInferenceConfig,
+ resolvedInferenceConfigOrigin,
+ }),
+ };
+ registerPendingRuntimeAction(nextRuntimeAction);
+ }
+ if (effects.reset_workspace) {
+ const resetResult = await resetFileWorkspace();
+ await appContext?.resetFileState?.();
+ if (workflow?.id) {
+ await updateWorkflowApi(workflow.id, {
+ metadata: {
+ project_context: null,
+ visualization_scales: null,
+ visualization_scales_source: null,
+ active_volume_pair: null,
+ needs_project_context: true,
+ },
+ });
+ await refreshWorkflow();
+ }
+ message.success(
+ `Workspace reset. Removed ${resetResult?.deleted_count ?? 0} indexed item(s).`,
+ );
+ }
+ if (effects.start_new_workflow) {
+ const resetBody =
+ typeof effects.start_new_workflow === "object"
+ ? effects.start_new_workflow
+ : {};
+ await resetFileWorkspace();
+ await clearLocalWorkflowInputs();
+ await startNewWorkflowApi(resetBody);
+ await refreshWorkflow();
+ }
+ if (effects.mount_project?.directory_path) {
+ const mountResult = await mountProjectDirectory({
+ directoryPath: effects.mount_project.directory_path,
+ mountName: effects.mount_project.mount_name || "",
+ destinationPath: effects.mount_project.destination_path || "root",
+ });
+ message.success(mountResult?.message || "Project mounted.");
+ if (effects.mount_project.workflow_patch && workflow?.id) {
+ await updateWorkflowApi(
+ workflow.id,
+ effects.mount_project.workflow_patch,
+ );
+ }
+ await refreshWorkflow();
+ }
+ if (effects.workflow_action?.kind === "propose_retraining_stage") {
+ const correctedMaskPath =
+ effects.workflow_action.corrected_mask_path ||
+ workflow?.corrected_mask_path ||
+ "";
+ if (workflow?.id && correctedMaskPath) {
+ await createAgentAction(workflow.id, {
+ action: "stage_retraining_from_corrections",
+ summary: "Stage corrected masks for retraining.",
+ payload: { corrected_mask_path: correctedMaskPath },
+ });
+ await refreshEvents();
+ message.info("Agent proposed a retraining handoff.");
+ } else {
+ message.warning("No corrected mask path is available to stage.");
+ }
+ }
+ if (effects.workflow_action?.kind === "compute_evaluation") {
+ const action = effects.workflow_action;
+ logClientEvent("workflow_action_compute_evaluation_started", {
+ source: "workflow_context",
+ message: "Assistant-triggered evaluation started",
+ data: { workflowId: workflow?.id || null },
+ });
+ if (
+ !workflow?.id ||
+ !action.baseline_prediction_path ||
+ !action.candidate_prediction_path ||
+ !action.ground_truth_path
+ ) {
+ message.warning(
+ "Metrics need a previous result, a new result, and a reference mask.",
+ );
+ } else {
+ try {
+ const result = await computeWorkflowEvaluationResult(workflow.id, {
+ name: action.name || "workflow-before-after-evaluation",
+ baseline_prediction_path: action.baseline_prediction_path,
+ candidate_prediction_path: action.candidate_prediction_path,
+ ground_truth_path: action.ground_truth_path,
+ baseline_run_id: action.baseline_run_id || undefined,
+ candidate_run_id: action.candidate_run_id || undefined,
+ model_version_id: action.model_version_id || undefined,
+ metadata: action.metadata || { source: "workflow_agent" },
+ });
+ message.success(result?.summary || "Metrics computed.");
+ await refreshEvidence();
+ await refreshEvents();
+ logClientEvent("workflow_action_compute_evaluation_completed", {
+ source: "workflow_context",
+ message: "Assistant-triggered evaluation completed",
+ data: {
+ workflowId: workflow.id,
+ evaluationId: result?.id || null,
+ },
+ });
+ } catch (error) {
+ logClientEvent("workflow_action_compute_evaluation_failed", {
+ level: "ERROR",
+ source: "workflow_context",
+ message: error.message || "Assistant-triggered evaluation failed",
+ data: { workflowId: workflow.id },
+ });
+ throw error;
+ }
+ }
+ }
+ if (effects.workflow_action?.kind === "export_bundle") {
+ logClientEvent("workflow_action_export_bundle_started", {
+ source: "workflow_context",
+ message: "Assistant-triggered evidence export started",
+ data: { workflowId: workflow?.id || null },
+ });
+ if (!workflow?.id) return;
+ try {
+ const bundle = await exportWorkflowBundle(workflow.id);
+ const missingCount = (bundle?.artifact_paths || []).filter(
+ (entry) => !entry.exists,
+ ).length;
+ message.success(
+ `Evidence exported: ${bundle?.artifacts?.length || 0} artifacts, ${missingCount} missing paths.`,
+ );
+ await refreshEvidence();
+ await refreshEvents();
+ logClientEvent("workflow_action_export_bundle_completed", {
+ source: "workflow_context",
+ message: "Assistant-triggered evidence export completed",
+ data: { workflowId: workflow.id, missingCount },
+ });
+ } catch (error) {
+ logClientEvent("workflow_action_export_bundle_failed", {
+ level: "ERROR",
+ source: "workflow_context",
+ message:
+ error.message || "Assistant-triggered evidence export failed",
+ data: { workflowId: workflow.id },
+ });
+ throw error;
+ }
+ }
+ if (effects.refresh_insights) {
+ await refreshInsights();
+ }
+ if (effects.refresh_project_progress) {
+ await refreshProjectProgress();
+ }
+ await refreshAgentRecommendation();
+ setLastClientEffects({ ...effects });
+ },
+ [
+ appContext,
+ clearLocalWorkflowInputs,
+ registerPendingRuntimeAction,
+ refreshAgentRecommendation,
+ refreshEvidence,
+ refreshEvents,
+ refreshWorkflow,
+ refreshInsights,
+ refreshProjectProgress,
+ workflow?.corrected_mask_path,
+ workflow?.id,
+ ],
+ );
+
+ const consumeRuntimeAction = useCallback(
+ (actionId = null) => {
+ setPendingRuntimeAction((current) => {
+ if (!current) return null;
+ if (!actionId || current.id === actionId) {
+ clearPersistedRuntimeAction();
+ return null;
+ }
+ return current;
+ });
+ },
+ [clearPersistedRuntimeAction],
+ );
+
+ const executeAssistantItem = useCallback(
+ async (item) => {
+ if (!item) return;
+ if (workflow?.id) {
+ try {
+ await appendEvent({
+ actor: "user",
+ event_type: "assistant.command.invoked",
+ stage: workflow.stage,
+ summary: `Executed assistant item: ${item.title || item.label || item.id || "action"}.`,
+ payload: {
+ item_id: item.id || null,
+ item_label: item.title || item.label || null,
+ item_type: item.command ? "command" : "action",
+ runtime_action: item.client_effects?.runtime_action || null,
+ },
+ });
+ } catch (_error) {
+ // Do not block command execution on audit-log failures.
+ }
+ }
+ await runClientEffects(item.client_effects);
},
- [appContext],
+ [appendEvent, runClientEffects, workflow?.id, workflow?.stage],
);
const approveAgentAction = useCallback(
- async (eventId) => {
+ async (eventId, overrides = {}) => {
if (!workflow?.id) return null;
- const result = await approveAgentActionApi(workflow.id, eventId);
+ const result = await approveAgentActionApi(
+ workflow.id,
+ eventId,
+ overrides,
+ );
if (result?.workflow) {
setWorkflow(result.workflow);
}
- applyClientEffects(result?.client_effects);
+ const durableCommand = result?.commands?.find?.((command) =>
+ Number.isFinite(Number(command?.id)),
+ );
+ if (durableCommand) {
+ const approvedEffects = result?.client_effects || {};
+ await runClientEffects(clientEffectsWithoutRuntime(approvedEffects));
+ const commandResult = await runWorkflowCommand(
+ workflow.id,
+ durableCommand.id,
+ );
+ if ((approvedEffects?.runtime_action || {}).kind === "start_training") {
+ registerPendingRuntimeAction(
+ {
+ id: `monitor_training:${Date.now()}`,
+ kind: "monitor_training",
+ commandId: durableCommand.id,
+ commandResult,
+ clientEffects: approvedEffects,
+ overrides: buildRuntimeOverridesFromEffects(approvedEffects),
+ },
+ true,
+ );
+ }
+ await Promise.allSettled([
+ refreshWorkflow(),
+ refreshEvents(),
+ refreshEvidence(),
+ refreshAgentRecommendation(),
+ refreshPreflight(),
+ refreshWorkflowOverview(),
+ refreshProjectProgress(),
+ ]);
+ } else {
+ await runClientEffects(result?.client_effects);
+ }
await refreshEvents();
message.success("Agent proposal approved.");
return result;
},
- [workflow?.id, refreshEvents, applyClientEffects],
+ [
+ clientEffectsWithoutRuntime,
+ registerPendingRuntimeAction,
+ refreshAgentRecommendation,
+ refreshEvents,
+ refreshEvidence,
+ refreshPreflight,
+ refreshWorkflowOverview,
+ refreshProjectProgress,
+ refreshWorkflow,
+ runClientEffects,
+ workflow?.id,
+ ],
);
const rejectAgentAction = useCallback(
@@ -166,15 +984,32 @@ export function WorkflowProvider({ children }) {
);
const queryAgent = useCallback(
- async (query) => {
+ async (query, conversationId = null) => {
if (!workflow?.id) return null;
- const result = await queryWorkflowAgent(workflow.id, query);
+ const result = await queryWorkflowAgent(
+ workflow.id,
+ query,
+ conversationId,
+ );
if (result?.proposals?.length) {
await refreshEvents();
}
+ await Promise.allSettled([
+ refreshWorkflow(),
+ refreshAgentRecommendation(),
+ refreshWorkflowOverview(),
+ refreshProjectProgress(),
+ ]);
return result;
},
- [workflow?.id, refreshEvents],
+ [
+ workflow?.id,
+ refreshEvents,
+ refreshWorkflow,
+ refreshAgentRecommendation,
+ refreshWorkflowOverview,
+ refreshProjectProgress,
+ ],
);
const consumeClientEffects = useCallback(() => {
@@ -190,17 +1025,37 @@ export function WorkflowProvider({ children }) {
events,
hotspots,
impactPreview,
+ agentRecommendation,
+ preflight,
+ workflowOverview,
+ projectProgress,
+ artifacts,
+ modelRuns,
+ modelVersions,
+ correctionSets,
+ evaluationResults,
loading,
lastClientEffects,
+ pendingRuntimeAction,
refreshWorkflow,
refreshEvents,
refreshInsights,
+ refreshAgentRecommendation,
+ refreshPreflight,
+ refreshWorkflowOverview,
+ refreshProjectProgress,
+ updateProjectProgressVolume,
+ refreshEvidence,
+ startNewWorkflow,
updateWorkflow,
appendEvent,
proposeAgentAction,
approveAgentAction,
rejectAgentAction,
queryAgent,
+ runClientEffects,
+ executeAssistantItem,
+ consumeRuntimeAction,
consumeClientEffects,
}}
>
diff --git a/client/src/contexts/WorkflowContext.test.js b/client/src/contexts/WorkflowContext.test.js
index 57549833..ce6602d1 100644
--- a/client/src/contexts/WorkflowContext.test.js
+++ b/client/src/contexts/WorkflowContext.test.js
@@ -5,23 +5,67 @@ import { AppContext } from "./GlobalContext";
import { WorkflowProvider, useWorkflow } from "./WorkflowContext";
import {
approveAgentAction,
+ appendWorkflowEvent,
+ computeWorkflowEvaluationResult,
+ createAgentAction,
+ exportWorkflowBundle,
+ getConfigPresetContent,
getCurrentWorkflow,
+ getWorkflowAgentRecommendation,
getWorkflowHotspots,
getWorkflowImpactPreview,
+ getWorkflowOverview,
+ getWorkflowPreflight,
+ getWorkflowProjectProgress,
+ listWorkflowArtifacts,
+ listWorkflowCorrectionSets,
listWorkflowEvents,
+ listWorkflowEvaluationResults,
+ listWorkflowModelRuns,
+ listWorkflowModelVersions,
+ mountProjectDirectory,
+ resetFileWorkspace,
+ runWorkflowCommand,
+ startNewWorkflow,
+ stopModelInference,
+ stopModelTraining,
+ updateWorkflowProjectProgressVolume,
} from "../api";
jest.mock("../api", () => ({
approveAgentAction: jest.fn(),
appendWorkflowEvent: jest.fn(),
+ computeWorkflowEvaluationResult: jest.fn(),
createAgentAction: jest.fn(),
+ exportWorkflowBundle: jest.fn(),
+ getConfigPresetContent: jest.fn(),
getCurrentWorkflow: jest.fn(),
+ getWorkflowAgentRecommendation: jest.fn(),
getWorkflowHotspots: jest.fn(),
getWorkflowImpactPreview: jest.fn(),
+ getWorkflowOverview: jest.fn(),
+ getWorkflowPreflight: jest.fn(),
+ getWorkflowProjectProgress: jest.fn(),
+ listWorkflowArtifacts: jest.fn(),
+ listWorkflowCorrectionSets: jest.fn(),
listWorkflowEvents: jest.fn(),
+ listWorkflowEvaluationResults: jest.fn(),
+ listWorkflowModelRuns: jest.fn(),
+ listWorkflowModelVersions: jest.fn(),
+ mountProjectDirectory: jest.fn(),
queryWorkflowAgent: jest.fn(),
rejectAgentAction: jest.fn(),
+ resetFileWorkspace: jest.fn(),
+ runWorkflowCommand: jest.fn(),
+ startNewWorkflow: jest.fn(),
+ stopModelInference: jest.fn(),
+ stopModelTraining: jest.fn(),
updateWorkflow: jest.fn(),
+ updateWorkflowProjectProgressVolume: jest.fn(),
+}));
+
+jest.mock("../logging/appEventLog", () => ({
+ logClientEvent: jest.fn(),
}));
const baseWorkflow = {
@@ -35,15 +79,191 @@ function Probe() {
return (
{workflowContext.workflow?.stage || "loading"}
-
{workflowContext.events.map((event) => event.event_type).join(",")}
+
+ {workflowContext.events.map((event) => event.event_type).join(",")}
+
{workflowContext.hotspots?.[0]?.summary || "no-hotspot"}
{workflowContext.impactPreview?.confidence || "no-impact"}
+
{workflowContext.agentRecommendation?.decision || "no-agent"}
+
{workflowContext.preflight?.overall_status || "no-preflight"}
+
{`artifacts:${workflowContext.artifacts.length}`}
+
{`runs:${workflowContext.modelRuns.length}`}
+
{`corrections:${workflowContext.correctionSets.length}`}
+
{`evaluations:${workflowContext.evaluationResults.length}`}
workflowContext.approveAgentAction(7)}
>
Approve proposal
+
+ workflowContext.runClientEffects({
+ navigate_to: "inference",
+ set_inference_output_path: "/tmp/inference-out",
+ })
+ }
+ >
+ Run effects
+
+
+ workflowContext.runClientEffects({
+ navigate_to: "visualization",
+ set_visualization_image_path: "/tmp/view-image.h5",
+ set_visualization_label_path: "/tmp/view-label.h5",
+ set_visualization_scales: [1, 1, 1],
+ })
+ }
+ >
+ View data effects
+
+
+ workflowContext.runClientEffects({
+ navigate_to: "inference",
+ set_inference_config_preset: "configs/MitoEM/Mito25-Local-BC.yaml",
+ set_inference_image_path: "/tmp/image.h5",
+ set_inference_label_path: "/tmp/mask.h5",
+ set_inference_checkpoint_path: "/tmp/checkpoint.pth.tar",
+ set_inference_output_path: "/tmp/out",
+ })
+ }
+ >
+ Configure inference
+
+
+ workflowContext.runClientEffects({
+ reset_workspace: true,
+ mount_project: {
+ directory_path: "/tmp/project",
+ mount_name: "project",
+ },
+ })
+ }
+ >
+ Reset remount
+
+
+ workflowContext.runClientEffects({
+ runtime_action: { kind: "stop_inference" },
+ })
+ }
+ >
+ Stop inference
+
+
+ workflowContext.executeAssistantItem({
+ id: "start-inference",
+ title: "Start inference in app",
+ command: "app inference run",
+ client_effects: {
+ navigate_to: "inference",
+ set_inference_image_path: "/tmp/runtime-image.h5",
+ set_inference_output_path: "/tmp/runtime-out",
+ runtime_action: { kind: "start_inference" },
+ },
+ })
+ }
+ >
+ Execute assistant item
+
+
+ workflowContext.runClientEffects({
+ navigate_to: "mask-proofreading",
+ set_proofreading_dataset_path: "/tmp/image.tif",
+ set_proofreading_mask_path: "/tmp/mask.tif",
+ set_proofreading_project_name: "Proofread me",
+ runtime_action: { kind: "start_proofreading" },
+ })
+ }
+ >
+ Start proofreading
+
+
+ workflowContext.runClientEffects({
+ workflow_action: {
+ kind: "propose_retraining_stage",
+ corrected_mask_path: "/tmp/corrected.tif",
+ },
+ refresh_insights: true,
+ })
+ }
+ >
+ Propose retraining
+
+
+ workflowContext.runClientEffects({
+ navigate_to: "training",
+ set_training_config_preset: "configs/MitoEM/Mito25-Local-BC.yaml",
+ set_training_image_path: "/tmp/image.h5",
+ set_training_label_path: "/tmp/corrected.tif",
+ set_training_output_path: "/tmp/output",
+ set_training_log_path: "/tmp/output",
+ runtime_action: {
+ kind: "start_training",
+ autopick_parameters: true,
+ },
+ })
+ }
+ >
+ Auto train
+
+
+ workflowContext.appendEvent({
+ actor: "system",
+ event_type: "test.event",
+ summary: "Append test event.",
+ })
+ }
+ >
+ Append event
+
+
+ {workflowContext.pendingRuntimeAction?.kind || "no-runtime-action"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.datasetPath ||
+ "no-dataset-override"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.inputImagePath ||
+ "no-training-image-override"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.inputLabelPath ||
+ "no-training-label-override"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.outputPath ||
+ "no-training-output-override"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.logPath ||
+ "no-training-log-override"}
+
+
+ {workflowContext.pendingRuntimeAction?.overrides?.autoParameters
+ ? "auto-parameters"
+ : "manual-parameters"}
+
);
}
@@ -65,20 +285,120 @@ describe("WorkflowProvider", () => {
workflow: baseWorkflow,
events: [{ id: 1, event_type: "workflow.created" }],
});
+ startNewWorkflow.mockResolvedValue({
+ workflow: baseWorkflow,
+ events: [{ id: 1, event_type: "workflow.created" }],
+ });
listWorkflowEvents.mockResolvedValue([]);
getWorkflowHotspots.mockResolvedValue({
workflow_id: 1,
- hotspots: [{ region_key: "z:9", summary: "Top hotspot", severity: "high" }],
+ hotspots: [
+ { region_key: "z:9", summary: "Top hotspot", severity: "high" },
+ ],
});
getWorkflowImpactPreview.mockResolvedValue({
workflow_id: 1,
confidence: "medium",
summary: "Impact summary",
});
+ getWorkflowAgentRecommendation.mockResolvedValue({
+ workflow_id: 1,
+ stage: "setup",
+ decision: "Proofread this data if the mask is ready.",
+ rationale:
+ "A human review pass is the fastest way to create useful edits.",
+ confidence: "medium",
+ next_stage: "proofreading",
+ readiness: [],
+ actions: [],
+ commands: [],
+ });
+ getWorkflowPreflight.mockResolvedValue({
+ workflow_id: 1,
+ overall_status: "image_only",
+ summary: "Image volume is loaded; add a checkpoint or mask/label next.",
+ items: [],
+ });
+ getWorkflowProjectProgress.mockResolvedValue({
+ workflow_id: 1,
+ summary: { total: 0, tracked_total: 0 },
+ volumes: [],
+ });
+ getWorkflowOverview.mockResolvedValue({
+ workflow_id: 1,
+ project_name: "Segmentation Workflow",
+ workflow_stage: "setup",
+ phase: "setup",
+ phase_label: "Setup",
+ phase_reason: "No project data is mounted yet.",
+ phase_index: 0,
+ volume_summary: { total: 0, tracked_total: 0 },
+ project_progress: {
+ workflow_id: 1,
+ summary: { total: 0, tracked_total: 0 },
+ volumes: [],
+ },
+ stages: [],
+ blockers: [],
+ recommended_next_actions: [],
+ active_runs: [],
+ recent_events: [],
+ });
+ updateWorkflowProjectProgressVolume.mockResolvedValue({
+ workflow_id: 1,
+ summary: { total: 1, tracked_total: 1, ground_truth: 1 },
+ volumes: [],
+ });
+ createAgentAction.mockResolvedValue({
+ id: 8,
+ event_type: "agent.proposal_created",
+ });
+ computeWorkflowEvaluationResult.mockResolvedValue({
+ id: 9,
+ summary: "Before/after evaluation computed.",
+ });
+ exportWorkflowBundle.mockResolvedValue({
+ artifacts: [{ id: 1 }],
+ artifact_paths: [],
+ });
+ mountProjectDirectory.mockResolvedValue({
+ mounted_root_id: 5,
+ message: "Project mounted.",
+ });
+ resetFileWorkspace.mockResolvedValue({
+ deleted_count: 2,
+ mounted_root_count: 1,
+ });
+ runWorkflowCommand.mockResolvedValue({ submitted: true });
+ stopModelInference.mockResolvedValue();
+ stopModelTraining.mockResolvedValue();
+ getConfigPresetContent.mockResolvedValue({
+ path: "configs/MitoEM/Mito25-Local-BC.yaml",
+ content: "DATASET: {}\nSOLVER: {}\n",
+ });
+ listWorkflowArtifacts.mockResolvedValue([
+ { id: 1, artifact_type: "image_volume" },
+ ]);
+ listWorkflowModelRuns.mockResolvedValue([{ id: 2, run_type: "inference" }]);
+ listWorkflowModelVersions.mockResolvedValue([]);
+ listWorkflowCorrectionSets.mockResolvedValue([
+ { id: 3, corrected_mask_path: "/tmp/corrected.tif" },
+ ]);
+ listWorkflowEvaluationResults.mockResolvedValue([
+ { id: 4, metrics: { summary: { dice_delta: 0.1 } } },
+ ]);
});
- it("loads the current workflow and events on startup", async () => {
- renderProvider({ trainingState: { setInputLabel: jest.fn() } });
+ it("resumes the current workflow and remounts the boot project on startup", async () => {
+ const resetFileState = jest.fn();
+ getCurrentWorkflow.mockResolvedValueOnce({
+ workflow: { ...baseWorkflow, dataset_path: "/tmp/boot-project" },
+ events: [{ id: 1, event_type: "workflow.created" }],
+ });
+ renderProvider({
+ resetFileState,
+ trainingState: { setInputLabel: jest.fn() },
+ });
expect(await screen.findByText("setup")).toBeTruthy();
expect(screen.getByText("workflow.created")).toBeTruthy();
@@ -89,8 +409,31 @@ describe("WorkflowProvider", () => {
await waitFor(() => {
expect(screen.getByText("Top hotspot")).toBeTruthy();
expect(screen.getByText("medium")).toBeTruthy();
+ expect(
+ screen.getByText("Proofread this data if the mask is ready."),
+ ).toBeTruthy();
+ expect(screen.getByText("image_only")).toBeTruthy();
});
- expect(getCurrentWorkflow).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(screen.getByText("artifacts:1")).toBeTruthy();
+ expect(screen.getByText("runs:1")).toBeTruthy();
+ expect(screen.getByText("corrections:1")).toBeTruthy();
+ expect(screen.getByText("evaluations:1")).toBeTruthy();
+ });
+ expect(listWorkflowArtifacts).toHaveBeenCalledWith(1);
+ expect(listWorkflowEvaluationResults).toHaveBeenCalledWith(1);
+ expect(resetFileWorkspace).not.toHaveBeenCalled();
+ expect(resetFileState).not.toHaveBeenCalled();
+ expect(startNewWorkflow).not.toHaveBeenCalled();
+ expect(mountProjectDirectory).toHaveBeenCalledWith({
+ directoryPath: "/tmp/boot-project",
+ mountName: baseWorkflow.title,
+ destinationPath: "root",
+ });
+ expect(getCurrentWorkflow).toHaveBeenCalled();
+ expect(getWorkflowAgentRecommendation).toHaveBeenCalledWith(1);
+ expect(getWorkflowPreflight).toHaveBeenCalledWith(1);
+ expect(getWorkflowOverview).toHaveBeenCalledWith(1, { refresh: true });
});
it("applies client effects when an agent proposal is approved", async () => {
@@ -115,4 +458,395 @@ describe("WorkflowProvider", () => {
expect(screen.getByText("retraining_staged")).toBeTruthy();
});
});
+
+ it("submits durable commands after approval without queueing runtime effects locally", async () => {
+ const setInputLabel = jest.fn();
+ const setInputImage = jest.fn();
+ const setOutputPath = jest.fn();
+ const setLogPath = jest.fn();
+ approveAgentAction.mockResolvedValue({
+ workflow: { ...baseWorkflow, stage: "training_ready" },
+ client_effects: {
+ navigate_to: "training",
+ set_training_image_path: "/tmp/image.h5",
+ set_training_label_path: "/tmp/corrected.tif",
+ set_training_output_path: "/tmp/training-output",
+ set_training_log_path: "/tmp/training-log",
+ runtime_action: { kind: "start_training" },
+ },
+ commands: [
+ {
+ id: 22,
+ title: "Start training",
+ command: "pytc train",
+ },
+ ],
+ });
+
+ renderProvider({
+ trainingState: {
+ setInputImage,
+ setInputLabel,
+ setOutputPath,
+ setLogPath,
+ },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Approve proposal"));
+
+ await waitFor(() => {
+ expect(setInputImage).toHaveBeenCalledWith("/tmp/image.h5");
+ expect(setInputLabel).toHaveBeenCalledWith("/tmp/corrected.tif");
+ expect(setOutputPath).toHaveBeenCalledWith("/tmp/training-output");
+ expect(setLogPath).toHaveBeenCalledWith("/tmp/training-log");
+ expect(runWorkflowCommand).toHaveBeenCalledWith(1, 22);
+ });
+ await waitFor(() => {
+ expect(screen.getByText("monitor_training")).toBeTruthy();
+ expect(screen.getByText("/tmp/image.h5")).toBeTruthy();
+ expect(screen.getByText("/tmp/corrected.tif")).toBeTruthy();
+ expect(screen.getByText("/tmp/training-output")).toBeTruthy();
+ expect(screen.getByText("/tmp/training-log")).toBeTruthy();
+ });
+ });
+
+ it("exposes direct client-effect execution for chat action cards", async () => {
+ const setOutputPath = jest.fn();
+
+ renderProvider({
+ trainingState: { setInputLabel: jest.fn() },
+ inferenceState: { setOutputPath },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Run effects"));
+
+ await waitFor(() => {
+ expect(setOutputPath).toHaveBeenCalledWith("/tmp/inference-out");
+ });
+ });
+
+ it("applies agent-selected visualization paths", async () => {
+ const setCurrentImage = jest.fn();
+ const setCurrentLabel = jest.fn();
+ const setVisualizationScales = jest.fn();
+
+ renderProvider({
+ setCurrentImage,
+ setCurrentLabel,
+ setVisualizationScales,
+ trainingState: { setInputLabel: jest.fn() },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("View data effects"));
+
+ await waitFor(() => {
+ expect(setCurrentImage).toHaveBeenCalledWith("/tmp/view-image.h5");
+ expect(setCurrentLabel).toHaveBeenCalledWith("/tmp/view-label.h5");
+ expect(setVisualizationScales).toHaveBeenCalledWith("1,1,1");
+ });
+ });
+
+ it("prefills inference config, inputs, checkpoint, and output", async () => {
+ const setInputImage = jest.fn();
+ const setInputLabel = jest.fn();
+ const setCheckpointPath = jest.fn();
+ const setOutputPath = jest.fn();
+ const setConfigOriginPath = jest.fn();
+ const setSelectedYamlPreset = jest.fn();
+ const setUploadedYamlFile = jest.fn();
+ const setInferenceConfig = jest.fn();
+
+ renderProvider({
+ setInferenceConfig,
+ inferenceState: {
+ setInputImage,
+ setInputLabel,
+ setCheckpointPath,
+ setOutputPath,
+ setConfigOriginPath,
+ setSelectedYamlPreset,
+ setUploadedYamlFile,
+ },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Configure inference"));
+
+ await waitFor(() => {
+ expect(setInferenceConfig).toHaveBeenCalledWith(
+ "DATASET: {}\nSOLVER: {}\n",
+ );
+ expect(setInputImage).toHaveBeenCalledWith("/tmp/image.h5");
+ expect(setInputLabel).toHaveBeenCalledWith("/tmp/mask.h5");
+ expect(setCheckpointPath).toHaveBeenCalledWith("/tmp/checkpoint.pth.tar");
+ expect(setOutputPath).toHaveBeenCalledWith("/tmp/out");
+ });
+ });
+
+ it("resets cached file state, remounts a project, and stops inference", async () => {
+ const resetFileState = jest.fn();
+
+ renderProvider({
+ resetFileState,
+ inferenceState: {
+ setOutputPath: jest.fn(),
+ setCheckpointPath: jest.fn(),
+ },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Reset remount"));
+
+ await waitFor(() => {
+ expect(resetFileWorkspace).toHaveBeenCalled();
+ expect(resetFileState).toHaveBeenCalled();
+ expect(mountProjectDirectory).toHaveBeenCalledWith({
+ directoryPath: "/tmp/project",
+ mountName: "project",
+ destinationPath: "root",
+ });
+ });
+
+ fireEvent.click(screen.getByText("Stop inference"));
+
+ await waitFor(() => {
+ expect(stopModelInference).toHaveBeenCalled();
+ expect(screen.getByText("stop_inference")).toBeTruthy();
+ });
+ });
+
+ it("queues runtime actions and logs assistant command execution", async () => {
+ const setOutputPath = jest.fn();
+
+ renderProvider({
+ trainingState: { setInputLabel: jest.fn(), setLogPath: jest.fn() },
+ inferenceState: { setOutputPath, setCheckpointPath: jest.fn() },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Execute assistant item"));
+
+ await waitFor(() => {
+ expect(setOutputPath).toHaveBeenCalledWith("/tmp/runtime-out");
+ });
+ await waitFor(() => {
+ expect(screen.getByText("start_inference")).toBeTruthy();
+ });
+ expect(screen.getByText("/tmp/runtime-image.h5")).toBeTruthy();
+ expect(appendWorkflowEvent).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({
+ actor: "user",
+ event_type: "assistant.command.invoked",
+ }),
+ );
+ });
+
+ it("loads agent-selected training config before queueing training", async () => {
+ const setInputImage = jest.fn();
+ const setInputLabel = jest.fn();
+ const setOutputPath = jest.fn();
+ const setLogPath = jest.fn();
+ const setConfigOriginPath = jest.fn();
+ const setSelectedYamlPreset = jest.fn();
+ const setUploadedYamlFile = jest.fn();
+ const setTrainingConfig = jest.fn();
+
+ renderProvider({
+ setTrainingConfig,
+ trainingState: {
+ setInputImage,
+ setInputLabel,
+ setOutputPath,
+ setLogPath,
+ setConfigOriginPath,
+ setSelectedYamlPreset,
+ setUploadedYamlFile,
+ },
+ inferenceState: {
+ setOutputPath: jest.fn(),
+ setCheckpointPath: jest.fn(),
+ },
+ });
+ await screen.findByText("setup");
+ fireEvent.click(screen.getByText("Auto train"));
+
+ await waitFor(() => {
+ expect(getConfigPresetContent).toHaveBeenCalledWith(
+ "configs/MitoEM/Mito25-Local-BC.yaml",
+ );
+ expect(setTrainingConfig).toHaveBeenCalledWith(
+ "DATASET: {}\nSOLVER: {}\n",
+ );
+ expect(setInputImage).toHaveBeenCalledWith("/tmp/image.h5");
+ expect(setInputLabel).toHaveBeenCalledWith("/tmp/corrected.tif");
+ expect(setOutputPath).toHaveBeenCalledWith("/tmp/output");
+ expect(screen.getByText("start_training")).toBeTruthy();
+ expect(screen.getByText("/tmp/image.h5")).toBeTruthy();
+ expect(screen.getByText("auto-parameters")).toBeTruthy();
+ });
+ });
+
+ it("queues proofreading runtime actions with image and mask overrides", async () => {
+ renderProvider({
+ trainingState: { setInputLabel: jest.fn(), setLogPath: jest.fn() },
+ inferenceState: {
+ setOutputPath: jest.fn(),
+ setCheckpointPath: jest.fn(),
+ },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Start proofreading"));
+
+ await waitFor(() => {
+ expect(screen.getByText("start_proofreading")).toBeTruthy();
+ expect(screen.getByText("/tmp/image.tif")).toBeTruthy();
+ });
+ });
+
+ it("creates approval-gated retraining proposals from client workflow actions", async () => {
+ renderProvider({
+ trainingState: { setInputLabel: jest.fn() },
+ inferenceState: { setOutputPath: jest.fn() },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Propose retraining"));
+
+ await waitFor(() => {
+ expect(createAgentAction).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({
+ action: "stage_retraining_from_corrections",
+ payload: { corrected_mask_path: "/tmp/corrected.tif" },
+ }),
+ );
+ });
+ expect(getWorkflowHotspots).toHaveBeenCalledWith(1);
+ });
+
+ it("does not throw when noncritical workflow event append fails", async () => {
+ appendWorkflowEvent.mockRejectedValueOnce(new Error("Network Error"));
+
+ renderProvider({
+ trainingState: { setInputLabel: jest.fn() },
+ inferenceState: { setOutputPath: jest.fn() },
+ });
+ await screen.findByText("setup");
+
+ fireEvent.click(screen.getByText("Append event"));
+
+ await waitFor(() => {
+ expect(appendWorkflowEvent).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ event_type: "test.event" }),
+ );
+ });
+ expect(screen.getByText("workflow.created")).toBeTruthy();
+ });
+
+ it("restores a persisted training monitor action after provider remount", async () => {
+ window.sessionStorage.setItem(
+ "pytc.workflow.pendingRuntimeAction.v1",
+ JSON.stringify({
+ kind: "pending_runtime_action",
+ workflowId: 1,
+ action: {
+ id: "monitor_training:123",
+ kind: "monitor_training",
+ commandId: 22,
+ commandResult: { submitted: true },
+ clientEffects: {
+ set_training_config_preset: "configs/MitoEM/Mito25-Local-BC.yaml",
+ set_training_image_path: "/persist/image.tif",
+ set_training_label_path: "/persist/label.tif",
+ set_training_output_path: "/persist/out",
+ set_training_log_path: "/persist/log.txt",
+ },
+ overrides: {
+ inputImagePath: "/persist/image.tif",
+ inputLabelPath: "/persist/label.tif",
+ outputPath: "/persist/out",
+ logPath: "/persist/log.txt",
+ },
+ },
+ savedAt: Date.now(),
+ }),
+ );
+
+ renderProvider({
+ setTrainingConfig: jest.fn(),
+ trainingState: {
+ setInputImage: jest.fn(),
+ setInputLabel: jest.fn(),
+ setOutputPath: jest.fn(),
+ setLogPath: jest.fn(),
+ },
+ inferenceState: {
+ setInputImage: jest.fn(),
+ setInputLabel: jest.fn(),
+ },
+ });
+
+ expect(await screen.findByText("monitor_training")).toBeTruthy();
+ const storageEntry = window.sessionStorage.getItem(
+ "pytc.workflow.pendingRuntimeAction.v1",
+ );
+ expect(storageEntry).toBeTruthy();
+ });
+
+ it("persists an approved durable training proposal as monitor training action", async () => {
+ const setInputImage = jest.fn();
+ const setInputLabel = jest.fn();
+ const setOutputPath = jest.fn();
+ const setLogPath = jest.fn();
+ approveAgentAction.mockResolvedValue({
+ workflow: { ...baseWorkflow, stage: "training_ready" },
+ client_effects: {
+ navigate_to: "training",
+ set_training_image_path: "/tmp/image.h5",
+ set_training_label_path: "/tmp/corrected.tif",
+ set_training_output_path: "/tmp/training-output",
+ set_training_log_path: "/tmp/training-log",
+ runtime_action: { kind: "start_training" },
+ },
+ commands: [
+ {
+ id: 22,
+ title: "Start training",
+ command: "pytc train",
+ },
+ ],
+ });
+
+ renderProvider({
+ setTrainingConfig: jest.fn(),
+ trainingState: {
+ setInputImage,
+ setInputLabel,
+ setOutputPath,
+ setLogPath,
+ },
+ inferenceState: {
+ setInputImage: jest.fn(),
+ setInputLabel: jest.fn(),
+ },
+ });
+
+ await screen.findByText("setup");
+ fireEvent.click(screen.getByText("Approve proposal"));
+
+ await waitFor(() => {
+ expect(screen.getByText("monitor_training")).toBeTruthy();
+ });
+ const persisted = JSON.parse(
+ window.sessionStorage.getItem("pytc.workflow.pendingRuntimeAction.v1"),
+ );
+ expect(persisted?.kind).toBe("pending_runtime_action");
+ expect(persisted?.action?.kind).toBe("monitor_training");
+ });
});
diff --git a/client/src/contexts/workflow/proposalCardConfig.js b/client/src/contexts/workflow/proposalCardConfig.js
index febf263a..c5cb8440 100644
--- a/client/src/contexts/workflow/proposalCardConfig.js
+++ b/client/src/contexts/workflow/proposalCardConfig.js
@@ -6,17 +6,37 @@ const toDisplayValue = (value) => {
return value.length ? value.join(", ") : "—";
}
if (typeof value === "object") {
- return JSON.stringify(value);
+ if (value.kind) return String(value.kind);
+ return "configured";
}
return String(value);
};
+const compactPath = (value) => {
+ if (!value) return "—";
+ const normalized = String(value).replace(/\\/g, "/");
+ const parts = normalized.split("/").filter(Boolean);
+ if (parts.length <= 3) return normalized;
+ return `.../${parts.slice(-3).join("/")}`;
+};
+
const compactRationale = (rationale) => {
if (!rationale) return "No rationale provided.";
const trimmed = String(rationale).trim();
return trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed;
};
+const field = (key, label, value, options = {}) => ({
+ key,
+ label,
+ rawValue: value,
+ value:
+ options.displayValue !== undefined
+ ? toDisplayValue(options.displayValue)
+ : toDisplayValue(value),
+ editable: Boolean(options.editable),
+});
+
const pickEntries = (proposal, keys) =>
keys
.filter((key) => key in proposal)
@@ -26,8 +46,99 @@ const pickEntries = (proposal, keys) =>
value: toDisplayValue(proposal[key]),
}));
+const CLIENT_EFFECT_FIELD_SPECS = [
+ ["set_training_config_preset", "Config", "config_preset"],
+ ["set_training_image_path", "Images", "image_path"],
+ ["set_training_label_path", "Labels", "label_path"],
+ ["set_training_output_path", "Output", "output_path"],
+ ["set_training_log_path", "Log", "log_path"],
+ ["set_inference_config_preset", "Config", "inference_config_preset"],
+ ["set_inference_checkpoint_path", "Checkpoint", "checkpoint_path"],
+ ["set_inference_image_path", "Image", "inference_image_path"],
+ ["set_inference_label_path", "Label", "inference_label_path"],
+ ["set_inference_output_path", "Output", "inference_output_path"],
+ ["set_visualization_image_path", "Image", "visualization_image_path"],
+ ["set_visualization_label_path", "Label", "visualization_label_path"],
+ ["set_visualization_scales", "Scales", "visualization_scales"],
+ ["set_proofreading_dataset_path", "Image", "proofreading_dataset_path"],
+ ["set_proofreading_mask_path", "Mask", "proofreading_mask_path"],
+ ["set_proofreading_project_name", "Project", "proofreading_project_name"],
+];
+
+const clientEffectFields = (clientEffects = {}) => {
+ if (!clientEffects || typeof clientEffects !== "object") return [];
+ return CLIENT_EFFECT_FIELD_SPECS.filter(
+ ([effectKey]) => clientEffects[effectKey] !== undefined,
+ ).map(([effectKey, label, overrideKey]) => {
+ const rawValue = clientEffects[effectKey];
+ const shouldCompact =
+ typeof rawValue === "string" &&
+ (rawValue.includes("/") || rawValue.includes("\\"));
+ return field(overrideKey, label, rawValue, {
+ editable: true,
+ displayValue: shouldCompact ? compactPath(rawValue) : rawValue,
+ });
+ });
+};
+
+const trainingSubsetFields = (subset = {}) => {
+ if (!subset || typeof subset !== "object") return [];
+ const statuses = Array.isArray(subset.training_statuses)
+ ? subset.training_statuses
+ : [];
+ const source =
+ statuses.length === 1 && statuses[0] === "ground_truth"
+ ? "fully good GT"
+ : statuses.join(", ") || "selected labels";
+ const fields = [];
+ if (subset.train_volume_count !== undefined) {
+ fields.push(
+ field(
+ "training_subset_train",
+ "Training set",
+ `${subset.train_volume_count} ${source} volume${
+ Number(subset.train_volume_count) === 1 ? "" : "s"
+ }`,
+ ),
+ );
+ }
+ if (subset.target_volume_count !== undefined) {
+ fields.push(
+ field(
+ "training_subset_targets",
+ "After training",
+ `${subset.target_volume_count} image-only target${
+ Number(subset.target_volume_count) === 1 ? "" : "s"
+ }`,
+ ),
+ );
+ }
+ if (subset.review_volume_count) {
+ fields.push(
+ field(
+ "training_subset_review",
+ "Left out",
+ `${subset.review_volume_count} draft mask${
+ Number(subset.review_volume_count) === 1 ? "" : "s"
+ }`,
+ ),
+ );
+ }
+ if (subset.manifest_path) {
+ fields.push(
+ field(
+ "training_subset_manifest",
+ "Manifest",
+ compactPath(subset.manifest_path),
+ ),
+ );
+ }
+ return fields;
+};
+
export const getProposalCardContent = (proposal = {}) => {
- const type = proposal.type || proposal.proposal_type || proposal.action || "proposal";
+ const type =
+ proposal.type || proposal.proposal_type || proposal.action || "proposal";
if (type === "prioritize_failure_hotspots") {
return {
@@ -70,12 +181,86 @@ export const getProposalCardContent = (proposal = {}) => {
"corrected_mask_path",
"written_path",
"training_output_path",
- ]),
+ ]).map((item) =>
+ [
+ "corrected_mask_path",
+ "written_path",
+ "training_output_path",
+ ].includes(item.key)
+ ? {
+ ...item,
+ rawValue: proposal[item.key],
+ value: compactPath(proposal[item.key]),
+ editable: true,
+ }
+ : item,
+ ),
+ };
+ }
+
+ if (type === "start_training_run") {
+ const subset = proposal.training_volume_subset;
+ return {
+ type,
+ title: "Approve Training Run",
+ rationale: compactRationale(
+ proposal.rationale ||
+ proposal.why ||
+ "Start training with the proposed inputs and safe defaults.",
+ ),
+ fields: [
+ field("config_preset", "Config", proposal.config_preset, {
+ editable: true,
+ displayValue: compactPath(proposal.config_preset),
+ }),
+ field("image_path", "Images", proposal.image_path, {
+ editable: true,
+ displayValue: compactPath(proposal.image_path),
+ }),
+ field("label_path", "Labels", proposal.label_path, {
+ editable: true,
+ displayValue: compactPath(proposal.label_path),
+ }),
+ field("output_path", "Output", proposal.output_path, {
+ editable: true,
+ displayValue: compactPath(proposal.output_path),
+ }),
+ ...trainingSubsetFields(subset),
+ field(
+ "parameters",
+ "Parameters",
+ proposal.autopick_parameters
+ ? "safe defaults"
+ : proposal.parameter_mode,
+ { editable: false },
+ ),
+ ].filter((item) => item.value !== "—"),
+ };
+ }
+
+ if (type === "run_client_effects") {
+ return {
+ type,
+ title: "Approve App Action",
+ rationale: compactRationale(
+ proposal.rationale ||
+ proposal.why ||
+ "Run the proposed assistant action inside the app.",
+ ),
+ fields: pickEntries(proposal, [
+ "item_label",
+ "item_type",
+ "risk_level",
+ "runtime_action",
+ "workflow_action",
+ ]).concat(clientEffectFields(proposal.client_effects)),
};
}
const fallbackFields = Object.entries(proposal)
- .filter(([key]) => !["type", "proposal_type", "rationale", "why"].includes(key))
+ .filter(
+ ([key]) => !["type", "proposal_type", "rationale", "why"].includes(key),
+ )
.slice(0, 4)
.map(([key, value]) => ({
key,
diff --git a/client/src/design/workflowDesignSystem.js b/client/src/design/workflowDesignSystem.js
new file mode 100644
index 00000000..75acf041
--- /dev/null
+++ b/client/src/design/workflowDesignSystem.js
@@ -0,0 +1,107 @@
+export const antdWorkflowTheme = {
+ token: {
+ colorPrimary: "#3f37c9",
+ colorInfo: "#3f37c9",
+ colorSuccess: "#2f7d32",
+ colorWarning: "#b8660f",
+ colorError: "#b13a2f",
+ borderRadius: 8,
+ fontFamily:
+ '"IBM Plex Sans", "Aptos", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif',
+ fontFamilyCode:
+ '"IBM Plex Mono", "SFMono-Regular", Menlo, Monaco, Consolas, monospace',
+ },
+};
+
+export const STAGE_META = {
+ setup: {
+ label: "Setup",
+ tone: "neutral",
+ color: "default",
+ description: "Define project context and source artifacts.",
+ },
+ visualization: {
+ label: "Visualization",
+ tone: "teal",
+ color: "cyan",
+ description: "Inspect source image and labels.",
+ },
+ inference: {
+ label: "Inference",
+ tone: "blue",
+ color: "blue",
+ description: "Run model prediction and inspect output.",
+ },
+ proofreading: {
+ label: "Proofreading",
+ tone: "amber",
+ color: "gold",
+ description: "Correct masks and classify failure regions.",
+ },
+ retraining_staged: {
+ label: "Retraining Staged",
+ tone: "orange",
+ color: "orange",
+ description: "Corrections are linked to the next training run.",
+ },
+ evaluation: {
+ label: "Evaluation",
+ tone: "green",
+ color: "green",
+ description: "Compare before/after model behavior.",
+ },
+};
+
+export const SEVERITY_META = {
+ low: { label: "Low", color: "default", tone: "neutral" },
+ medium: { label: "Medium", color: "orange", tone: "amber" },
+ high: { label: "High", color: "red", tone: "red" },
+};
+
+export const APPROVAL_META = {
+ not_required: { label: "Logged", color: "default", tone: "neutral" },
+ pending: { label: "Needs Review", color: "gold", tone: "amber" },
+ approved: { label: "Approved", color: "green", tone: "green" },
+ rejected: { label: "Rejected", color: "red", tone: "red" },
+};
+
+export const ARTIFACT_META = {
+ dataset: { label: "Dataset", color: "blue" },
+ image_volume: { label: "Image", color: "cyan" },
+ label_volume: { label: "Label", color: "geekblue" },
+ mask_volume: { label: "Mask", color: "purple" },
+ correction_set: { label: "CorrectionSet", color: "gold" },
+ model_checkpoint: { label: "Checkpoint", color: "green" },
+ training_output: { label: "Training Run", color: "lime" },
+ inference_output: { label: "Prediction", color: "blue" },
+ evaluation_report: { label: "Evaluation", color: "volcano" },
+ evidence_bundle: { label: "Evidence Bundle", color: "magenta" },
+};
+
+export function getStageMeta(stage) {
+ return (
+ STAGE_META[stage] || {
+ label: stage || "No Workflow",
+ tone: "neutral",
+ color: "default",
+ description: "No active workflow stage.",
+ }
+ );
+}
+
+export function getSeverityMeta(severity) {
+ return SEVERITY_META[severity] || SEVERITY_META.low;
+}
+
+export function getApprovalMeta(status) {
+ return APPROVAL_META[status] || APPROVAL_META.not_required;
+}
+
+export function getArtifactMeta(type) {
+ return (
+ ARTIFACT_META[type] || {
+ label: type || "Artifact",
+ color: "default",
+ }
+ );
+}
diff --git a/client/src/electronApi.js b/client/src/electronApi.js
index 172c3079..2d90b243 100644
--- a/client/src/electronApi.js
+++ b/client/src/electronApi.js
@@ -14,6 +14,10 @@ export function openLocalFile(options = {}) {
return api.openLocalFile(options);
}
+export function canOpenLocalFile() {
+ return Boolean(getElectronAPI()?.openLocalFile);
+}
+
export function revealInFinder(targetPath) {
const api = getElectronAPI();
if (!api) {
diff --git a/client/src/index.js b/client/src/index.js
index df868170..71d95547 100644
--- a/client/src/index.js
+++ b/client/src/index.js
@@ -1,11 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
+import { ConfigProvider } from "antd";
import "./index.css";
import App from "./App";
+import { antdWorkflowTheme } from "./design/workflowDesignSystem";
+import { installClientLogging } from "./logging/appEventLog";
+
+installClientLogging();
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
-
+
+
+
,
);
diff --git a/client/src/logging/appEventLog.js b/client/src/logging/appEventLog.js
new file mode 100644
index 00000000..49b29dc0
--- /dev/null
+++ b/client/src/logging/appEventLog.js
@@ -0,0 +1,597 @@
+const isLocalHost = (hostname) =>
+ /^(localhost|127\.0\.0\.1)$/.test(hostname || "");
+
+const removeTrailingSlash = (value) => value.replace(/\/+$/, "");
+
+const getDefaultBaseUrl = () => {
+ if (
+ typeof window !== "undefined" &&
+ window.location?.origin &&
+ !isLocalHost(window.location.hostname)
+ ) {
+ return `${window.location.origin}/api`;
+ }
+
+ return `${process.env.REACT_APP_SERVER_PROTOCOL || "http"}://${process.env.REACT_APP_SERVER_URL || "localhost:4242"}`;
+};
+
+const BASE_URL = removeTrailingSlash(
+ process.env.REACT_APP_API_BASE_URL || getDefaultBaseUrl(),
+);
+const LOG_ENDPOINT = `${BASE_URL}/app/log-event`;
+const CLIENT_SESSION_ID = `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+const envFlagEnabled = (name, defaultValue = true) => {
+ const rawValue = process.env[name];
+ if (rawValue === undefined || rawValue === "") return defaultValue;
+ return !["0", "false", "off", "no"].includes(String(rawValue).toLowerCase());
+};
+
+const CAPTURE_CONSOLE_LOGS = envFlagEnabled(
+ "REACT_APP_CAPTURE_CONSOLE_LOGS",
+ true,
+);
+const CAPTURE_BROWSER_NETWORK = envFlagEnabled(
+ "REACT_APP_CAPTURE_BROWSER_NETWORK",
+ true,
+);
+const CAPTURE_DOM_EVENTS = envFlagEnabled("REACT_APP_CAPTURE_DOM_EVENTS", true);
+const CAPTURE_RESOURCE_TIMING = envFlagEnabled(
+ "REACT_APP_CAPTURE_RESOURCE_TIMING",
+ true,
+);
+const MAX_SEND_FAILURES = 3;
+const DOM_EVENT_TYPES = [
+ "click",
+ "dblclick",
+ "submit",
+ "change",
+ "input",
+ "keydown",
+ "keyup",
+ "focusin",
+ "focusout",
+ "dragstart",
+ "drop",
+ "paste",
+];
+const LOG_ENDPOINT_PATH = (() => {
+ try {
+ return new URL(LOG_ENDPOINT).pathname;
+ } catch {
+ return "/app/log-event";
+ }
+})();
+
+let installed = false;
+const originalConsole = {};
+let originalFetch = null;
+let originalXHROpen = null;
+let originalXHRSend = null;
+let originalPushState = null;
+let originalReplaceState = null;
+let sendFailures = 0;
+let loggingDisabledUntil = 0;
+let lastDomEventAtByKey = {};
+
+const normalizeValue = (value, depth = 0, seen = new WeakSet()) => {
+ if (depth > 3) {
+ return "[max-depth]";
+ }
+ if (
+ value === null ||
+ value === undefined ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return value;
+ }
+ if (value instanceof Error) {
+ return {
+ name: value.name,
+ message: value.message,
+ stack: value.stack,
+ };
+ }
+ if (Array.isArray(value)) {
+ return value
+ .slice(0, 20)
+ .map((item) => normalizeValue(item, depth + 1, seen));
+ }
+ if (typeof value === "object") {
+ if (seen.has(value)) {
+ return "[circular]";
+ }
+ seen.add(value);
+ const output = {};
+ Object.entries(value)
+ .slice(0, 20)
+ .forEach(([key, item]) => {
+ output[key] = normalizeValue(item, depth + 1, seen);
+ });
+ return output;
+ }
+ return String(value);
+};
+
+const normalizeUrl = (url) => {
+ if (!url) return "";
+ if (typeof url === "string") return url;
+ if (typeof URL !== "undefined" && url instanceof URL) return url.toString();
+ if (typeof Request !== "undefined" && url instanceof Request) return url.url;
+ return String(url);
+};
+
+const shouldSkipLogUrl = (url) => {
+ const normalized = normalizeUrl(url);
+ if (!normalized) return false;
+ try {
+ const parsed = new URL(
+ normalized,
+ typeof window !== "undefined" ? window.location.href : undefined,
+ );
+ return parsed.pathname === LOG_ENDPOINT_PATH;
+ } catch {
+ return normalized.includes("/app/log-event");
+ }
+};
+
+const nowMs = () =>
+ typeof performance !== "undefined" ? performance.now() : Date.now();
+
+const formatConsoleArgs = (args) =>
+ args
+ .map((arg) => {
+ if (typeof arg === "string") return arg;
+ try {
+ return JSON.stringify(normalizeValue(arg));
+ } catch {
+ return String(arg);
+ }
+ })
+ .join(" ");
+
+const sendPayload = (payload) => {
+ if (
+ sendFailures >= MAX_SEND_FAILURES &&
+ typeof Date !== "undefined" &&
+ Date.now() < loggingDisabledUntil
+ ) {
+ return;
+ }
+
+ const body = JSON.stringify(payload);
+
+ try {
+ const fetchImpl =
+ originalFetch ||
+ (typeof window !== "undefined" && window.fetch
+ ? window.fetch.bind(window)
+ : null);
+ if (!fetchImpl) return;
+
+ fetchImpl(LOG_ENDPOINT, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ keepalive: true,
+ })
+ .then((response) => {
+ if (response.ok) {
+ sendFailures = 0;
+ return;
+ }
+ sendFailures += 1;
+ if (sendFailures >= MAX_SEND_FAILURES) {
+ loggingDisabledUntil = Date.now() + 60000;
+ }
+ })
+ .catch(() => {
+ sendFailures += 1;
+ if (sendFailures >= MAX_SEND_FAILURES) {
+ loggingDisabledUntil = Date.now() + 60000;
+ }
+ });
+ } catch {
+ // Swallow client logging failures.
+ }
+};
+
+export const logClientEvent = (
+ event,
+ { level = "INFO", message = "", data = null, source = "client", url } = {},
+) => {
+ sendPayload({
+ event,
+ level,
+ message,
+ data: data ? normalizeValue(data) : undefined,
+ source,
+ sessionId: CLIENT_SESSION_ID,
+ url:
+ url || (typeof window !== "undefined" ? window.location.href : undefined),
+ });
+};
+
+const installConsoleLogging = () => {
+ ["log", "info", "warn", "error", "debug"].forEach((method) => {
+ const original = console[method].bind(console);
+ originalConsole[method] = original;
+ console[method] = (...args) => {
+ original(...args);
+ logClientEvent("console_output", {
+ level: method === "debug" ? "INFO" : method.toUpperCase(),
+ message: formatConsoleArgs(args),
+ data: { method, args: args.map((arg) => normalizeValue(arg)) },
+ source: "console",
+ });
+ };
+ });
+};
+
+const summarizeElement = (target) => {
+ if (!target || !target.tagName) return {};
+ const text = String(target.innerText || target.textContent || "")
+ .replace(/\s+/g, " ")
+ .trim();
+ const value =
+ "value" in target && typeof target.value === "string" ? target.value : "";
+ return {
+ tagName: target.tagName,
+ id: target.id || "",
+ className: String(target.className || "").slice(0, 160),
+ name: target.name || "",
+ role: target.getAttribute?.("role") || "",
+ ariaLabel: target.getAttribute?.("aria-label") || "",
+ title: target.getAttribute?.("title") || "",
+ textPreview: text.slice(0, 120),
+ valueLength: value.length,
+ checked: typeof target.checked === "boolean" ? target.checked : undefined,
+ href: target.href || undefined,
+ src: target.src || undefined,
+ };
+};
+
+const shouldLogDomEvent = (event) => {
+ if (event.type !== "input") return true;
+ const key = [
+ event.type,
+ event.target?.tagName || "",
+ event.target?.id || "",
+ event.target?.name || "",
+ event.target?.className || "",
+ ].join("|");
+ const current = Date.now();
+ if (current - (lastDomEventAtByKey[key] || 0) < 750) {
+ return false;
+ }
+ lastDomEventAtByKey[key] = current;
+ return true;
+};
+
+const installDomEventLogging = () => {
+ DOM_EVENT_TYPES.forEach((eventType) => {
+ document.addEventListener(
+ eventType,
+ (event) => {
+ if (!shouldLogDomEvent(event)) return;
+ logClientEvent(`dom_${eventType}`, {
+ level: "INFO",
+ message: `${eventType} ${event.target?.tagName || "target"}`,
+ source: "dom",
+ data: {
+ eventType,
+ key: event.key,
+ code: event.code,
+ altKey: event.altKey,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ shiftKey: event.shiftKey,
+ button: event.button,
+ target: summarizeElement(event.target),
+ },
+ });
+ },
+ true,
+ );
+ });
+
+ document.addEventListener(
+ "error",
+ (event) => {
+ const target = event.target;
+ if (!target || target === window) return;
+ logClientEvent("resource_error", {
+ level: "ERROR",
+ message: `Resource failed to load: ${target.tagName || "target"}`,
+ source: "resource",
+ data: { target: summarizeElement(target) },
+ });
+ },
+ true,
+ );
+};
+
+const installFetchLogging = () => {
+ if (typeof window.fetch !== "function") return;
+ originalFetch = window.fetch.bind(window);
+ window.fetch = async (...args) => {
+ const [input, init = {}] = args;
+ const url = normalizeUrl(input);
+ const startedAt = nowMs();
+ const skip = shouldSkipLogUrl(url);
+ if (!skip) {
+ logClientEvent("browser_fetch_request", {
+ level: "INFO",
+ message: `${init?.method || input?.method || "GET"} ${url}`,
+ source: "fetch",
+ data: {
+ method: init?.method || input?.method || "GET",
+ url,
+ keepalive: Boolean(init?.keepalive),
+ },
+ });
+ }
+ try {
+ const response = await originalFetch(...args);
+ if (!skip) {
+ logClientEvent("browser_fetch_response", {
+ level: response.ok ? "INFO" : "ERROR",
+ message: `${init?.method || input?.method || "GET"} ${url} -> ${response.status}`,
+ source: "fetch",
+ data: {
+ method: init?.method || input?.method || "GET",
+ url,
+ status: response.status,
+ ok: response.ok,
+ latencyMs: Number((nowMs() - startedAt).toFixed(2)),
+ },
+ });
+ }
+ return response;
+ } catch (error) {
+ if (!skip) {
+ logClientEvent("browser_fetch_failed", {
+ level: "ERROR",
+ message: `${init?.method || input?.method || "GET"} ${url} failed`,
+ source: "fetch",
+ data: {
+ method: init?.method || input?.method || "GET",
+ url,
+ latencyMs: Number((nowMs() - startedAt).toFixed(2)),
+ error: normalizeValue(error),
+ },
+ });
+ }
+ throw error;
+ }
+ };
+};
+
+const installXhrLogging = () => {
+ if (typeof XMLHttpRequest === "undefined") return;
+ originalXHROpen = XMLHttpRequest.prototype.open;
+ originalXHRSend = XMLHttpRequest.prototype.send;
+
+ XMLHttpRequest.prototype.open = function open(method, url, ...rest) {
+ this.__pytcLog = {
+ method: method || "GET",
+ url: normalizeUrl(url),
+ startedAt: null,
+ skip: shouldSkipLogUrl(url),
+ };
+ return originalXHROpen.call(this, method, url, ...rest);
+ };
+
+ XMLHttpRequest.prototype.send = function send(body) {
+ const meta = this.__pytcLog || {};
+ meta.startedAt = nowMs();
+ if (!meta.skip) {
+ logClientEvent("browser_xhr_request", {
+ level: "INFO",
+ message: `${meta.method || "GET"} ${meta.url}`,
+ source: "xhr",
+ data: {
+ method: meta.method || "GET",
+ url: meta.url,
+ bodyType: body ? Object.prototype.toString.call(body) : null,
+ },
+ });
+ }
+
+ this.addEventListener("loadend", () => {
+ if (meta.skip) return;
+ logClientEvent("browser_xhr_response", {
+ level: this.status >= 400 ? "ERROR" : "INFO",
+ message: `${meta.method || "GET"} ${meta.url} -> ${this.status}`,
+ source: "xhr",
+ data: {
+ method: meta.method || "GET",
+ url: meta.url,
+ status: this.status,
+ latencyMs:
+ meta.startedAt !== null
+ ? Number((nowMs() - meta.startedAt).toFixed(2))
+ : null,
+ },
+ });
+ });
+
+ this.addEventListener("error", () => {
+ if (meta.skip) return;
+ logClientEvent("browser_xhr_failed", {
+ level: "ERROR",
+ message: `${meta.method || "GET"} ${meta.url} failed`,
+ source: "xhr",
+ data: {
+ method: meta.method || "GET",
+ url: meta.url,
+ latencyMs:
+ meta.startedAt !== null
+ ? Number((nowMs() - meta.startedAt).toFixed(2))
+ : null,
+ },
+ });
+ });
+
+ return originalXHRSend.call(this, body);
+ };
+};
+
+const logNavigation = (event, extra = {}) => {
+ logClientEvent(event, {
+ level: "INFO",
+ message: `${event}: ${window.location.href}`,
+ source: "navigation",
+ data: {
+ href: window.location.href,
+ pathname: window.location.pathname,
+ search: window.location.search,
+ hash: window.location.hash,
+ ...extra,
+ },
+ });
+};
+
+const installNavigationLogging = () => {
+ originalPushState = window.history.pushState;
+ originalReplaceState = window.history.replaceState;
+ window.history.pushState = function pushState(state, title, url) {
+ const result = originalPushState.apply(this, arguments);
+ logNavigation("history_push_state", {
+ targetUrl: normalizeUrl(url),
+ state,
+ });
+ return result;
+ };
+ window.history.replaceState = function replaceState(state, title, url) {
+ const result = originalReplaceState.apply(this, arguments);
+ logNavigation("history_replace_state", {
+ targetUrl: normalizeUrl(url),
+ state,
+ });
+ return result;
+ };
+ window.addEventListener("popstate", (event) =>
+ logNavigation("history_pop_state", { state: event.state }),
+ );
+ window.addEventListener("hashchange", () => logNavigation("hash_change"));
+ window.addEventListener("online", () => logNavigation("browser_online"));
+ window.addEventListener("offline", () => logNavigation("browser_offline"));
+ document.addEventListener("visibilitychange", () =>
+ logClientEvent("visibility_change", {
+ level: "INFO",
+ message: `document visibility: ${document.visibilityState}`,
+ source: "browser",
+ data: { visibilityState: document.visibilityState },
+ }),
+ );
+ window.addEventListener("focus", () => logNavigation("window_focus"));
+ window.addEventListener("blur", () => logNavigation("window_blur"));
+ window.addEventListener("message", (event) => {
+ logClientEvent("window_message", {
+ level: "INFO",
+ message: `message from ${event.origin || "unknown origin"}`,
+ source: "window",
+ data: {
+ origin: event.origin,
+ data: normalizeValue(event.data),
+ },
+ });
+ });
+};
+
+const installResourceTimingLogging = () => {
+ if (
+ typeof PerformanceObserver === "undefined" ||
+ !PerformanceObserver.supportedEntryTypes?.includes("resource")
+ ) {
+ return;
+ }
+ const observer = new PerformanceObserver((list) => {
+ list.getEntries().forEach((entry) => {
+ if (shouldSkipLogUrl(entry.name)) return;
+ logClientEvent("resource_timing", {
+ level: "INFO",
+ message: `${entry.initiatorType || "resource"} ${entry.name}`,
+ source: "performance",
+ data: {
+ name: entry.name,
+ initiatorType: entry.initiatorType,
+ durationMs: Number(entry.duration?.toFixed?.(2) || 0),
+ transferSize: entry.transferSize,
+ encodedBodySize: entry.encodedBodySize,
+ decodedBodySize: entry.decodedBodySize,
+ nextHopProtocol: entry.nextHopProtocol,
+ },
+ });
+ });
+ });
+ observer.observe({ type: "resource", buffered: true });
+};
+
+export const installClientLogging = () => {
+ if (installed || typeof window === "undefined") {
+ return;
+ }
+ installed = true;
+ originalFetch =
+ typeof window.fetch === "function"
+ ? window.fetch.bind(window)
+ : originalFetch;
+
+ if (CAPTURE_CONSOLE_LOGS) {
+ installConsoleLogging();
+ }
+ if (CAPTURE_BROWSER_NETWORK) {
+ installFetchLogging();
+ installXhrLogging();
+ }
+ installNavigationLogging();
+ if (CAPTURE_DOM_EVENTS) {
+ installDomEventLogging();
+ }
+ if (CAPTURE_RESOURCE_TIMING) {
+ installResourceTimingLogging();
+ }
+
+ window.addEventListener("error", (event) => {
+ if (
+ typeof event.message === "string" &&
+ event.message.includes("ResizeObserver loop completed")
+ ) {
+ return;
+ }
+ logClientEvent("window_error", {
+ level: "ERROR",
+ message: event.message || "Unhandled window error",
+ data: {
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ error: normalizeValue(event.error),
+ },
+ source: "window",
+ });
+ });
+
+ window.addEventListener("unhandledrejection", (event) => {
+ logClientEvent("unhandled_rejection", {
+ level: "ERROR",
+ message: "Unhandled promise rejection",
+ data: {
+ reason: normalizeValue(event.reason),
+ },
+ source: "window",
+ });
+ });
+
+ logClientEvent("app_boot", {
+ level: "INFO",
+ message: "React client booted",
+ data: {
+ userAgent: navigator.userAgent,
+ },
+ source: "client",
+ });
+};
diff --git a/client/src/logging/appEventLog.test.js b/client/src/logging/appEventLog.test.js
new file mode 100644
index 00000000..44b5e024
--- /dev/null
+++ b/client/src/logging/appEventLog.test.js
@@ -0,0 +1,65 @@
+const loadAppEventLogModule = (baseUrl) => {
+ const originalFetch = window.fetch;
+ const fetchMock = jest.fn().mockResolvedValue({ ok: true });
+ window.fetch = fetchMock;
+ process.env.REACT_APP_API_BASE_URL = baseUrl;
+
+ jest.resetModules();
+ const appEventLog = require("./appEventLog");
+
+ return { appEventLog, fetchMock, originalFetch };
+};
+
+describe("appEventLog canonicalization", () => {
+ let originalFetch;
+
+ beforeEach(() => {
+ originalFetch = window.fetch;
+ });
+
+ afterEach(() => {
+ delete process.env.REACT_APP_API_BASE_URL;
+ if (originalFetch) {
+ window.fetch = originalFetch;
+ }
+ jest.clearAllMocks();
+ });
+
+ it("posts logging events to /app/log-event when base URL already has /api", async () => {
+ const { appEventLog, fetchMock } = loadAppEventLogModule(
+ "https://demo.example/api",
+ );
+
+ appEventLog.logClientEvent("api_test_event", {
+ message: "from test",
+ level: "INFO",
+ });
+
+ await Promise.resolve();
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://demo.example/api/app/log-event",
+ expect.objectContaining({
+ method: "POST",
+ }),
+ );
+ });
+
+ it("posts logging events to the app log endpoint on the configured base", async () => {
+ const { appEventLog, fetchMock } = loadAppEventLogModule(
+ "https://demo.example",
+ );
+
+ appEventLog.logClientEvent("api_test_event", {
+ message: "from test",
+ level: "INFO",
+ });
+
+ await Promise.resolve();
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://demo.example/app/log-event",
+ expect.objectContaining({ method: "POST" }),
+ );
+ });
+});
diff --git a/client/src/logging/configLogSummary.js b/client/src/logging/configLogSummary.js
new file mode 100644
index 00000000..b9288716
--- /dev/null
+++ b/client/src/logging/configLogSummary.js
@@ -0,0 +1,168 @@
+import yaml from "js-yaml";
+
+const DIRECT_VOLUME_SUFFIXES = [
+ ".h5",
+ ".hdf5",
+ ".hdf",
+ ".tif",
+ ".tiff",
+ ".ome.tif",
+ ".ome.tiff",
+ ".zarr",
+ ".n5",
+ ".npy",
+ ".npz",
+ ".nii",
+ ".nii.gz",
+ ".mrc",
+ ".map",
+ ".rec",
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".bmp",
+];
+const VALID_INFERENCE_AUG_NUMS = [4, 8, 16];
+
+const getNestedValue = (data, path) =>
+ path.reduce(
+ (current, key) =>
+ current && current[key] !== undefined ? current[key] : undefined,
+ data,
+ );
+
+const trimPath = (value) =>
+ typeof value === "string" && value.trim() ? value.trim() : null;
+
+const pathBasename = (value) => {
+ const normalized = trimPath(value);
+ if (!normalized) return null;
+ const segments = normalized.split(/[/\\]/);
+ return segments[segments.length - 1] || normalized;
+};
+
+const looksLikeDirectVolume = (value) => {
+ const normalized = trimPath(value)?.toLowerCase();
+ if (!normalized) return false;
+ return DIRECT_VOLUME_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
+};
+
+export const summarizeConfigObject = (configObj, modeHint = null) => {
+ if (!configObj || typeof configObj !== "object") {
+ return null;
+ }
+
+ const datasetImageName = getNestedValue(configObj, ["DATASET", "IMAGE_NAME"]);
+ const datasetLabelName = getNestedValue(configObj, ["DATASET", "LABEL_NAME"]);
+ const inferenceImageName = getNestedValue(configObj, [
+ "INFERENCE",
+ "IMAGE_NAME",
+ ]);
+
+ return {
+ modeHint,
+ architecture: getNestedValue(configObj, ["MODEL", "ARCHITECTURE"]) || null,
+ blockType: getNestedValue(configObj, ["MODEL", "BLOCK_TYPE"]) || null,
+ dataset: {
+ imageName: pathBasename(datasetImageName),
+ labelName: pathBasename(datasetLabelName),
+ outputPath: trimPath(
+ getNestedValue(configObj, ["DATASET", "OUTPUT_PATH"]),
+ ),
+ doChunkTitle: getNestedValue(configObj, ["DATASET", "DO_CHUNK_TITLE"]),
+ validRatio: getNestedValue(configObj, ["DATASET", "VALID_RATIO"]),
+ rejectSamplingProbability: getNestedValue(configObj, [
+ "DATASET",
+ "REJECT_SAMPLING",
+ "P",
+ ]),
+ isAbsolutePath: getNestedValue(configObj, [
+ "DATASET",
+ "IS_ABSOLUTE_PATH",
+ ]),
+ usesDirectVolumePaths:
+ looksLikeDirectVolume(datasetImageName) ||
+ looksLikeDirectVolume(datasetLabelName),
+ },
+ inference: {
+ imageName: pathBasename(inferenceImageName),
+ outputPath: trimPath(
+ getNestedValue(configObj, ["INFERENCE", "OUTPUT_PATH"]),
+ ),
+ augNum: getNestedValue(configObj, ["INFERENCE", "AUG_NUM"]),
+ samplesPerBatch: getNestedValue(configObj, [
+ "INFERENCE",
+ "SAMPLES_PER_BATCH",
+ ]),
+ },
+ solver: {
+ baseLr: getNestedValue(configObj, ["SOLVER", "BASE_LR"]),
+ batchSize: getNestedValue(configObj, ["SOLVER", "SAMPLES_PER_BATCH"]),
+ totalIterations: getNestedValue(configObj, ["SOLVER", "ITERATION_TOTAL"]),
+ },
+ system: {
+ numGpus: getNestedValue(configObj, ["SYSTEM", "NUM_GPUS"]),
+ numCpus: getNestedValue(configObj, ["SYSTEM", "NUM_CPUS"]),
+ parallel: getNestedValue(configObj, ["SYSTEM", "PARALLEL"]),
+ distributed: getNestedValue(configObj, ["SYSTEM", "DISTRIBUTED"]),
+ },
+ };
+};
+
+export const summarizeConfigText = (configText, modeHint = null) => {
+ const summary = {
+ modeHint,
+ textLength: configText?.length || 0,
+ lineCount: configText ? configText.split("\n").length : 0,
+ };
+
+ if (!configText) {
+ return summary;
+ }
+
+ try {
+ const parsed = yaml.load(configText);
+ return {
+ ...summary,
+ parsed: true,
+ config: summarizeConfigObject(parsed, modeHint),
+ };
+ } catch (error) {
+ return {
+ ...summary,
+ parsed: false,
+ parseError: error.message || "Unknown YAML parse error",
+ };
+ }
+};
+
+export const detectConfigDiagnostics = (summary) => {
+ const diagnostics = [];
+ const config = summary?.config;
+ if (!config) {
+ return diagnostics;
+ }
+
+ if (config.dataset?.doChunkTitle && config.dataset?.usesDirectVolumePaths) {
+ diagnostics.push({
+ code: "tile_dataset_direct_volume_mismatch",
+ severity: "warning",
+ message:
+ "DO_CHUNK_TITLE is enabled while IMAGE_NAME/LABEL_NAME look like direct volume files.",
+ });
+ }
+
+ if (
+ config.inference?.augNum !== null &&
+ config.inference?.augNum !== undefined &&
+ !VALID_INFERENCE_AUG_NUMS.includes(config.inference.augNum)
+ ) {
+ diagnostics.push({
+ code: "unsupported_inference_aug_num",
+ severity: "warning",
+ message: `INFERENCE.AUG_NUM is ${config.inference.augNum}; supported values are ${VALID_INFERENCE_AUG_NUMS.join(", ")}.`,
+ });
+ }
+
+ return diagnostics;
+};
diff --git a/client/src/runtime/modelLaunch.js b/client/src/runtime/modelLaunch.js
new file mode 100644
index 00000000..aea5975a
--- /dev/null
+++ b/client/src/runtime/modelLaunch.js
@@ -0,0 +1,354 @@
+import yaml from "js-yaml";
+import { startModelInference, startModelTraining } from "../api";
+import { applyInputPaths, detectConfigSchema } from "../configSchema";
+import { logClientEvent } from "../logging/appEventLog";
+import {
+ detectConfigDiagnostics,
+ summarizeConfigText,
+} from "../logging/configLogSummary";
+
+export function getPathValue(value) {
+ if (!value) return "";
+ if (typeof value === "string") return value;
+ return value.path || value.originFileObj?.path || value.folderPath || "";
+}
+
+function getTrainingConfigOriginPath(trainingState) {
+ return (
+ trainingState?.configOriginPath ||
+ trainingState?.selectedYamlPreset ||
+ getPathValue(trainingState?.uploadedYamlFile)
+ );
+}
+
+function getInferenceConfigOriginPath(inferenceState) {
+ return (
+ inferenceState?.configOriginPath ||
+ inferenceState?.selectedYamlPreset ||
+ getPathValue(inferenceState?.uploadedYamlFile)
+ );
+}
+
+function parseConfigText(configText) {
+ if (!configText) return null;
+ try {
+ const yamlData = yaml.load(configText);
+ return yamlData && typeof yamlData === "object" ? yamlData : null;
+ } catch (error) {
+ return null;
+ }
+}
+
+function resolveInferenceConfigSource(appContext, overrides = {}) {
+ const inferenceState = appContext?.inferenceState || {};
+ const trainingState = appContext?.trainingState || {};
+ const explicitInferenceConfig = overrides.inferenceConfig;
+ const explicitOriginPath = overrides.configOriginPath;
+ const inferenceConfig =
+ explicitInferenceConfig ?? appContext?.inferenceConfig;
+ const inferenceOriginPath =
+ explicitOriginPath || getInferenceConfigOriginPath(inferenceState);
+ const trainingConfig = appContext?.trainingConfig;
+ const trainingOriginPath = getTrainingConfigOriginPath(trainingState);
+
+ if (explicitInferenceConfig || explicitOriginPath) {
+ return {
+ configText: inferenceConfig,
+ originPath: inferenceOriginPath,
+ source: "inference",
+ reason: null,
+ };
+ }
+
+ const inferenceSchema = detectConfigSchema(parseConfigText(inferenceConfig));
+ const trainingSchema = detectConfigSchema(parseConfigText(trainingConfig));
+ const needsTrainingFallback =
+ Boolean(trainingConfig) &&
+ (!inferenceConfig ||
+ (inferenceSchema !== "legacy" && trainingSchema === "legacy") ||
+ (inferenceOriginPath &&
+ inferenceOriginPath.startsWith("tutorials/") &&
+ trainingSchema === "legacy"));
+
+ if (!needsTrainingFallback) {
+ return {
+ configText: inferenceConfig,
+ originPath: inferenceOriginPath,
+ source: "inference",
+ reason: null,
+ };
+ }
+
+ let reason = "missing_inference_config";
+ if (inferenceConfig) {
+ if (inferenceSchema !== "legacy" && trainingSchema === "legacy") {
+ reason = "incompatible_inference_schema";
+ } else if (inferenceOriginPath?.startsWith("tutorials/")) {
+ reason = "stale_inference_preset";
+ }
+ }
+
+ logClientEvent("inference_config_fallback_to_training", {
+ level: "WARNING",
+ message: "Inference launch fell back to the latest training config",
+ source: "modelLaunch",
+ data: {
+ reason,
+ inferenceOriginPath,
+ trainingOriginPath,
+ inferenceSchema,
+ trainingSchema,
+ },
+ });
+
+ return {
+ configText: trainingConfig,
+ originPath: trainingOriginPath,
+ source: "training",
+ reason,
+ };
+}
+
+function prepareConfig(configText, mutator) {
+ try {
+ const yamlData = yaml.load(configText);
+ if (!yamlData || typeof yamlData !== "object") {
+ return configText;
+ }
+ mutator(yamlData);
+ return yaml.dump(yamlData, { indent: 2 }).replace(/^\s*\n/gm, "");
+ } catch (error) {
+ console.warn("Failed to prepare model launch config:", error);
+ logClientEvent("model_launch_config_prepare_failed", {
+ level: "WARNING",
+ message: "Failed to prepare model launch config",
+ source: "modelLaunch",
+ data: {
+ error: error.message || "unknown error",
+ configLength: configText?.length || 0,
+ },
+ });
+ return configText;
+ }
+}
+
+function ensureObject(parent, key) {
+ if (!parent[key] || typeof parent[key] !== "object") {
+ parent[key] = {};
+ }
+ return parent[key];
+}
+
+function applyAgentTrainingDefaults(yamlData) {
+ const solver = ensureObject(yamlData, "SOLVER");
+ const system = ensureObject(yamlData, "SYSTEM");
+ const model = ensureObject(yamlData, "MODEL");
+ const inference = ensureObject(yamlData, "INFERENCE");
+ const dataset = ensureObject(yamlData, "DATASET");
+
+ // Memory-safe defaults: large biomedical volumes are more likely to fail from
+ // over-aggressive batches than from conservative throughput.
+ solver.SAMPLES_PER_BATCH = 1;
+ solver.ITERATION_SAVE = 80;
+ solver.ITERATION_TOTAL = 80;
+ solver.ITERATION_VAL = 80;
+ solver.WARMUP_ITERS = 10;
+ system.NUM_CPUS = 2;
+ system.NUM_GPUS = 1;
+ model.INPUT_SIZE = [65, 65, 65];
+ model.OUTPUT_SIZE = [65, 65, 65];
+ model.FILTERS = [8, 12, 16, 24, 32];
+ dataset.PAD_SIZE = [4, 16, 16];
+ inference.INPUT_SIZE = [65, 65, 65];
+ inference.OUTPUT_SIZE = [65, 65, 65];
+ inference.STRIDE = [32, 32, 32];
+ inference.SAMPLES_PER_BATCH = 1;
+ inference.PAD_SIZE = [4, 16, 16];
+}
+
+export function buildTrainingLaunchRequest(
+ appContext,
+ workflowId = null,
+ overrides = {},
+) {
+ const trainingState = appContext?.trainingState || {};
+ const trainingConfig = overrides.trainingConfig || appContext?.trainingConfig;
+
+ if (!trainingConfig) {
+ throw new Error(
+ "Please load a preset or upload a YAML configuration first.",
+ );
+ }
+
+ const outputPath = getPathValue(
+ overrides.outputPath ?? trainingState.outputPath,
+ );
+ if (!outputPath) {
+ throw new Error("Please set output path first in Step 1.");
+ }
+
+ const inputImagePath = getPathValue(
+ overrides.inputImagePath ?? trainingState.inputImage,
+ );
+ const inputLabelPath = getPathValue(
+ overrides.inputLabelPath ?? trainingState.inputLabel,
+ );
+ const logPath =
+ getPathValue(overrides.logPath ?? trainingState.logPath) || outputPath;
+ const configOriginPath =
+ overrides.configOriginPath || getTrainingConfigOriginPath(trainingState);
+
+ const preparedTrainingConfig = prepareConfig(trainingConfig, (yamlData) => {
+ applyInputPaths(yamlData, {
+ mode: "training",
+ inputImagePath,
+ inputLabelPath,
+ inputPath: "",
+ outputPath,
+ });
+ if (overrides.autoParameters) {
+ applyAgentTrainingDefaults(yamlData);
+ }
+ });
+ const configSummary = summarizeConfigText(preparedTrainingConfig, "training");
+ const diagnostics = detectConfigDiagnostics(configSummary);
+
+ logClientEvent("training_launch_request_built", {
+ level: diagnostics.length ? "WARNING" : "INFO",
+ message: "Training launch request built",
+ source: "modelLaunch",
+ data: {
+ workflowId: overrides.workflowId ?? workflowId,
+ configOriginPath,
+ outputPath,
+ logPath,
+ inputImagePath,
+ inputLabelPath,
+ autoParameters: Boolean(overrides.autoParameters),
+ configSummary,
+ diagnostics,
+ },
+ });
+
+ return {
+ trainingConfig: preparedTrainingConfig,
+ logPath,
+ outputPath,
+ inputImagePath,
+ inputLabelPath,
+ configOriginPath,
+ workflowId: overrides.workflowId ?? workflowId,
+ autoParameters: Boolean(overrides.autoParameters),
+ };
+}
+
+export async function launchTrainingFromContext(
+ appContext,
+ workflowId = null,
+ overrides = {},
+) {
+ const request = buildTrainingLaunchRequest(appContext, workflowId, overrides);
+ return startModelTraining(
+ request.trainingConfig,
+ request.logPath,
+ request.outputPath,
+ request.configOriginPath,
+ request.workflowId,
+ request.autoParameters,
+ request.inputImagePath,
+ request.inputLabelPath,
+ );
+}
+
+export function buildInferenceLaunchRequest(
+ appContext,
+ workflowId = null,
+ overrides = {},
+) {
+ const inferenceState = appContext?.inferenceState || {};
+ const inferenceConfigSource = resolveInferenceConfigSource(
+ appContext,
+ overrides,
+ );
+ const inferenceConfig = inferenceConfigSource.configText;
+
+ if (!inferenceConfig) {
+ throw new Error("Please load or upload an inference configuration first.");
+ }
+
+ const checkpointPath = getPathValue(
+ overrides.checkpointPath ?? inferenceState.checkpointPath,
+ );
+ if (!checkpointPath) {
+ throw new Error("Please set checkpoint path first.");
+ }
+
+ const outputPath = getPathValue(
+ overrides.outputPath ?? inferenceState.outputPath,
+ );
+ const inputImagePath = getPathValue(
+ overrides.inputImagePath ?? inferenceState.inputImage,
+ );
+ const configOriginPath = inferenceConfigSource.originPath;
+
+ const preparedInferenceConfig = prepareConfig(inferenceConfig, (yamlData) => {
+ applyInputPaths(yamlData, {
+ mode: "inference",
+ inputImagePath,
+ inputLabelPath: "",
+ inputPath: "",
+ outputPath,
+ });
+ });
+ const configSummary = summarizeConfigText(
+ preparedInferenceConfig,
+ "inference",
+ );
+ const diagnostics = detectConfigDiagnostics(configSummary);
+
+ logClientEvent("inference_launch_request_built", {
+ level: diagnostics.length ? "WARNING" : "INFO",
+ message: "Inference launch request built",
+ source: "modelLaunch",
+ data: {
+ workflowId: overrides.workflowId ?? workflowId,
+ configOriginPath,
+ configSource: inferenceConfigSource.source,
+ configFallbackReason: inferenceConfigSource.reason,
+ outputPath,
+ checkpointPath,
+ inputImagePath,
+ configSummary,
+ diagnostics,
+ },
+ });
+
+ return {
+ inferenceConfig: preparedInferenceConfig,
+ outputPath,
+ checkpointPath,
+ inputImagePath,
+ configOriginPath,
+ workflowId: overrides.workflowId ?? workflowId,
+ };
+}
+
+export async function launchInferenceFromContext(
+ appContext,
+ workflowId = null,
+ overrides = {},
+) {
+ const request = buildInferenceLaunchRequest(
+ appContext,
+ workflowId,
+ overrides,
+ );
+ return startModelInference(
+ request.inferenceConfig,
+ request.outputPath,
+ request.checkpointPath,
+ request.configOriginPath,
+ request.workflowId,
+ request.inputImagePath,
+ );
+}
diff --git a/client/src/runtime/modelLaunch.test.js b/client/src/runtime/modelLaunch.test.js
new file mode 100644
index 00000000..a1c78051
--- /dev/null
+++ b/client/src/runtime/modelLaunch.test.js
@@ -0,0 +1,153 @@
+import yaml from "js-yaml";
+
+import {
+ buildInferenceLaunchRequest,
+ buildTrainingLaunchRequest,
+} from "./modelLaunch";
+
+jest.mock("../api", () => ({
+ startModelInference: jest.fn(),
+ startModelTraining: jest.fn(),
+}));
+
+describe("modelLaunch helpers", () => {
+ it("builds a training launch request from app context state", () => {
+ const request = buildTrainingLaunchRequest(
+ {
+ trainingConfig: `
+DATASET:
+ INPUT_PATH: ""
+ IMAGE_NAME: ""
+ LABEL_NAME: ""
+ OUTPUT_PATH: ""
+`,
+ trainingState: {
+ inputImage: "/tmp/image.tif",
+ inputLabel: "/tmp/label.tif",
+ outputPath: "/tmp/train-out",
+ logPath: "",
+ configOriginPath: "/tmp/training.yaml",
+ },
+ },
+ 7,
+ );
+
+ const parsed = yaml.load(request.trainingConfig);
+ expect(parsed.DATASET.OUTPUT_PATH).toBe("/tmp/train-out");
+ expect(request.logPath).toBe("/tmp/train-out");
+ expect(request.configOriginPath).toBe("/tmp/training.yaml");
+ expect(request.workflowId).toBe(7);
+ });
+
+ it("applies agent-selected training defaults from runtime overrides", () => {
+ const request = buildTrainingLaunchRequest(
+ {
+ trainingState: {},
+ },
+ 7,
+ {
+ trainingConfig: `
+DATASET:
+ INPUT_PATH: ""
+ IMAGE_NAME: ""
+ LABEL_NAME: ""
+ OUTPUT_PATH: ""
+SOLVER: {}
+SYSTEM: {}
+`,
+ inputImagePath: "/tmp/image.h5",
+ inputLabelPath: "/tmp/corrected.tif",
+ outputPath: "/tmp/agent-training",
+ logPath: "/tmp/agent-training",
+ configOriginPath: "configs/MitoEM/Mito25-Local-BC.yaml",
+ autoParameters: true,
+ },
+ );
+
+ const parsed = yaml.load(request.trainingConfig);
+ expect(parsed.DATASET.IMAGE_NAME).toBe("/tmp/image.h5");
+ expect(parsed.DATASET.LABEL_NAME).toBe("/tmp/corrected.tif");
+ expect(parsed.DATASET.OUTPUT_PATH).toBe("/tmp/agent-training");
+ expect(parsed.SOLVER.SAMPLES_PER_BATCH).toBe(1);
+ expect(parsed.SOLVER.ITERATION_SAVE).toBe(80);
+ expect(parsed.SOLVER.ITERATION_TOTAL).toBe(80);
+ expect(parsed.SOLVER.ITERATION_VAL).toBe(80);
+ expect(parsed.SYSTEM.NUM_CPUS).toBe(2);
+ expect(parsed.SYSTEM.NUM_GPUS).toBe(1);
+ expect(parsed.MODEL.INPUT_SIZE).toEqual([65, 65, 65]);
+ expect(parsed.MODEL.OUTPUT_SIZE).toEqual([65, 65, 65]);
+ expect(parsed.MODEL.FILTERS).toEqual([8, 12, 16, 24, 32]);
+ expect(parsed.INFERENCE.STRIDE).toEqual([32, 32, 32]);
+ expect(request.autoParameters).toBe(true);
+ expect(request.configOriginPath).toBe(
+ "configs/MitoEM/Mito25-Local-BC.yaml",
+ );
+ });
+
+ it("uses runtime overrides when building an inference launch request", () => {
+ const request = buildInferenceLaunchRequest(
+ {
+ inferenceConfig: `
+INFERENCE:
+ OUTPUT_PATH: ""
+`,
+ inferenceState: {
+ inputImage: "/tmp/default-image.tif",
+ outputPath: "/tmp/default-out",
+ checkpointPath: "/tmp/default.ckpt",
+ },
+ },
+ 9,
+ {
+ outputPath: "/tmp/runtime-out",
+ checkpointPath: "/tmp/runtime.ckpt",
+ },
+ );
+
+ expect(request.outputPath).toBe("/tmp/runtime-out");
+ expect(request.checkpointPath).toBe("/tmp/runtime.ckpt");
+ expect(request.workflowId).toBe(9);
+ });
+
+ it("rejects inference launches without a checkpoint", () => {
+ expect(() =>
+ buildInferenceLaunchRequest({
+ inferenceConfig: "INFERENCE: {}",
+ inferenceState: {},
+ }),
+ ).toThrow("Please set checkpoint path first.");
+ });
+
+ it("falls back to the training config when the persisted inference config is incompatible", () => {
+ const request = buildInferenceLaunchRequest({
+ trainingConfig: `
+DATASET:
+ INPUT_PATH: ""
+ IMAGE_NAME: ""
+INFERENCE:
+ OUTPUT_PATH: ""
+`,
+ inferenceConfig: `
+default:
+ inference:
+ batch_size: 1
+`,
+ trainingState: {
+ configOriginPath: "configs/MitoEM/Mito25-Local-BC.yaml",
+ },
+ inferenceState: {
+ configOriginPath: "tutorials/neuron_nisb_9nm_base.yaml",
+ inputImage: "/tmp/inference-image.h5",
+ outputPath: "/tmp/inference-out",
+ checkpointPath: "/tmp/model.ckpt",
+ },
+ });
+
+ const parsed = yaml.load(request.inferenceConfig);
+ expect(parsed.DATASET.IMAGE_NAME).toBe("/tmp/inference-image.h5");
+ expect(parsed.INFERENCE.IMAGE_NAME).toBe("/tmp/inference-image.h5");
+ expect(request.configOriginPath).toBe(
+ "configs/MitoEM/Mito25-Local-BC.yaml",
+ );
+ });
+});
diff --git a/client/src/utils/projectSuggestions.js b/client/src/utils/projectSuggestions.js
new file mode 100644
index 00000000..67da826f
--- /dev/null
+++ b/client/src/utils/projectSuggestions.js
@@ -0,0 +1,661 @@
+export const PROJECT_ROLE_LABELS = {
+ image: "image",
+ label: "label",
+ prediction: "prediction",
+ config: "config",
+ checkpoint: "checkpoint",
+ volume: "other volume",
+};
+
+export const joinProjectPath = (rootPath, relativePath) => {
+ if (!rootPath || !relativePath) return "";
+ return `${String(rootPath).replace(/\/+$/, "")}/${String(relativePath).replace(/^\/+/, "")}`;
+};
+
+const isAbsoluteOrUriPath = (path) =>
+ /^([a-z]+:)?\/\//i.test(String(path || "")) ||
+ String(path || "").startsWith("/");
+
+export const normalizeProjectRolePath = (rootPath, rolePath) => {
+ if (!rolePath) return "";
+ return isAbsoluteOrUriPath(rolePath)
+ ? String(rolePath)
+ : joinProjectPath(rootPath, rolePath);
+};
+
+const normalizeVoxelSizeNm = (value) => {
+ if (!value) return null;
+ const values = Array.isArray(value)
+ ? value
+ : String(value).match(/\d+(?:\.\d+)?/g);
+ if (!values || values.length < 3) return null;
+ const numeric = values.slice(0, 3).map((item) => Number(item));
+ return numeric.every((item) => Number.isFinite(item) && item > 0)
+ ? numeric
+ : null;
+};
+
+const scaleUnitMultiplier = (unitText) => {
+ const lower = String(unitText || "").toLowerCase();
+ return /(?:\u00b5m|um|micron|microns)/.test(lower) ? 1000 : 1;
+};
+
+const parseVoxelSizeNmFromText = (text) => {
+ const source = String(text || "");
+ if (!source.trim()) return null;
+
+ const threeAxisPattern =
+ /(?:voxel(?:\s+(?:size|spacing))?|resolution|spacing|pixel\s+size|scale)[^\d]{0,60}(\d+(?:\.\d+)?)\s*(?:x|by|,|;|\/|-|\s)\s*(\d+(?:\.\d+)?)\s*(?:x|by|,|;|\/|-|\s)\s*(\d+(?:\.\d+)?)(?:\s*(nm|nanometers?|um|\u00b5m|microns?))?/i;
+ const threeAxisMatch = source.match(threeAxisPattern);
+ if (threeAxisMatch) {
+ const multiplier = scaleUnitMultiplier(threeAxisMatch[4] || source);
+ return threeAxisMatch
+ .slice(1, 4)
+ .map((value) => Number(value) * multiplier);
+ }
+
+ const unitQualifiedTriple =
+ /(?:at|@)?\s*(\d+(?:\.\d+)?)\s*(?:x|by|,|;|\/|-)\s*(\d+(?:\.\d+)?)\s*(?:x|by|,|;|\/|-)\s*(\d+(?:\.\d+)?)\s*(nm|nanometers?|um|\u00b5m|microns?)/i;
+ const unitQualifiedMatch = source.match(unitQualifiedTriple);
+ if (unitQualifiedMatch) {
+ const multiplier = scaleUnitMultiplier(unitQualifiedMatch[4]);
+ return unitQualifiedMatch
+ .slice(1, 4)
+ .map((value) => Number(value) * multiplier);
+ }
+
+ const isotropicPattern =
+ /(?:isotropic|cubic|same\s+voxel|same\s+scale)[^\d]{0,60}(\d+(?:\.\d+)?)(?:\s*(nm|nanometers?|um|\u00b5m|microns?))?/i;
+ const isotropicMatch = source.match(isotropicPattern);
+ if (isotropicMatch) {
+ const value =
+ Number(isotropicMatch[1]) *
+ scaleUnitMultiplier(isotropicMatch[2] || source);
+ return [value, value, value];
+ }
+
+ return null;
+};
+
+const formatScaleNumber = (value) =>
+ Number.isInteger(Number(value))
+ ? String(Number(value))
+ : `${Number(value).toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
+
+export const formatVoxelSizeNm = (value) => {
+ const voxelSize = normalizeVoxelSizeNm(value);
+ if (!voxelSize) return "";
+ return `${voxelSize.map(formatScaleNumber).join(" x ")} nm`;
+};
+
+export const getProjectVolumeSetsFromSuggestion = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ return profile.volume_sets || profile.schema?.volume_sets || [];
+};
+
+export const getProjectAuditFromSuggestion = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ return profile.audit || profile.schema?.audit || null;
+};
+
+export const getProjectContextDefaultsFromSuggestion = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ const contentHints = {
+ ...(profile.schema?.context_hints || {}),
+ ...(profile.context_hints || {}),
+ ...(suggestion?.context_hints || {}),
+ };
+ const counts = profile.counts || {};
+ const auditFacts =
+ getProjectAuditFromSuggestion(suggestion)?.context_facts || [];
+ const defaults = {};
+
+ [
+ "imaging_modality",
+ "target_structure",
+ "task_family",
+ "mask_status",
+ "image_only_strategy",
+ "training_policy",
+ ].forEach((key) => {
+ if (contentHints[key]) {
+ defaults[key] = contentHints[key];
+ }
+ });
+ const hintedVoxelSize = normalizeVoxelSizeNm(
+ contentHints.voxel_size_nm || contentHints.visualization_scales,
+ );
+ if (hintedVoxelSize) {
+ defaults.voxel_size_nm = hintedVoxelSize;
+ defaults.voxel_size_source =
+ contentHints.voxel_size_source || "project_profile";
+ }
+ const auditVoxelSizeFact = auditFacts.find(
+ (fact) => fact?.key === "voxel_size_nm" && fact.value,
+ );
+ const auditVoxelSize = normalizeVoxelSizeNm(auditVoxelSizeFact?.value);
+ if (auditVoxelSize) {
+ defaults.voxel_size_nm = auditVoxelSize;
+ defaults.voxel_size_source = auditVoxelSizeFact.source || "volume_metadata";
+ }
+
+ const modalityText = String(defaults.imaging_modality || "").toLowerCase();
+ const targetText = String(defaults.target_structure || "").toLowerCase();
+ const suggestionText = [
+ suggestion?.id,
+ suggestion?.name,
+ suggestion?.description,
+ suggestion?.directory_path,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ if (!defaults.task_family) {
+ if (/(xri|x-ray|xray)/.test(`${modalityText} ${suggestionText}`)) {
+ defaults.task_family = /fiber|fibre|cytotape/.test(
+ `${targetText} ${suggestionText}`,
+ )
+ ? "XRI fibre instance segmentation"
+ : "XRI volumetric segmentation";
+ } else if (/mitochond/.test(targetText)) {
+ defaults.task_family = "mitochondria instance segmentation";
+ } else if (/synap|cleft/.test(targetText)) {
+ defaults.task_family = "synapse segmentation";
+ } else if (/nuclei|nucleus/.test(targetText)) {
+ defaults.task_family = "nuclei instance segmentation";
+ }
+ }
+ const hasDomainContext = Boolean(
+ defaults.imaging_modality ||
+ defaults.target_structure ||
+ defaults.task_family ||
+ contentHints.task_goal,
+ );
+ if (hasDomainContext && !defaults.mask_status) {
+ if ((counts.image || 0) > 0 && (counts.label || 0) === 0) {
+ defaults.mask_status = "image-only; no masks found";
+ } else if (
+ (counts.image || 0) > (counts.label || 0) &&
+ (counts.label || 0) > 0
+ ) {
+ defaults.mask_status = "mixed: some masks, some image-only volumes";
+ } else if ((counts.label || 0) > 0) {
+ defaults.mask_status = "masks found; confirm ground truth vs draft";
+ }
+ }
+ if (
+ hasDomainContext &&
+ !defaults.image_only_strategy &&
+ (counts.image || 0) > (counts.label || 0)
+ ) {
+ defaults.image_only_strategy = "run inference on image-only volumes later";
+ }
+ if (
+ hasDomainContext &&
+ !defaults.training_policy &&
+ (counts.label || 0) > 0
+ ) {
+ defaults.training_policy = "train only on confirmed ground-truth masks";
+ }
+
+ return defaults;
+};
+
+export const inferProjectContextFromDescription = (description) => {
+ const text = String(description || "").trim();
+ if (!text) return null;
+
+ const lower = text.toLowerCase();
+ const normalizedLower = lower.replace(/[^a-z0-9+-]+/g, " ");
+ const paddedLower = ` ${normalizedLower} `;
+ const context = {
+ freeform_note: text.slice(0, 1000),
+ };
+
+ const modalityTerms = [
+ ["X-ray / XRI volumetric microscopy", ["xri", "x-ray", "xray"]],
+ ["electron microscopy", ["electron microscopy", "electron microscope"]],
+ ["EM", [" em ", "sem", "tem", "fib-sem", "fib sem"]],
+ ["confocal microscopy", ["confocal"]],
+ ["light-sheet microscopy", ["light sheet", "light-sheet"]],
+ ["fluorescence microscopy", ["fluorescence", "fluorescent"]],
+ ["brightfield microscopy", ["brightfield", "bright-field"]],
+ ["MRI", [" mri ", "mri"]],
+ ["CT", [" ct ", "micro-ct", "micro ct"]],
+ ];
+
+ const modality = modalityTerms.find(([, terms]) =>
+ terms.some((term) => paddedLower.includes(term)),
+ );
+ if (modality) {
+ context.imaging_modality = modality[0];
+ }
+
+ const targetTerms = [
+ ["fibres", ["fiber", "fibers", "fibre", "fibres", "cytotape"]],
+ ["mitochondria", ["mitochondria", "mitochondrion", "mito"]],
+ ["membranes", ["membrane", "membranes"]],
+ ["synapses", ["synapse", "synapses"]],
+ ["nuclei", ["nucleus", "nuclei"]],
+ ["cells", ["cell", "cells"]],
+ ["neurites", ["neurite", "neurites", "axon", "dendrite"]],
+ ["vasculature", ["vessel", "vasculature", "blood vessel"]],
+ ["organelle", ["organelle", "organelles"]],
+ ];
+
+ const target = targetTerms.find(([, terms]) =>
+ terms.some((term) => lower.includes(term)),
+ );
+ if (target) {
+ context.target_structure = target[0];
+ }
+
+ if (
+ /xri|x-ray|xray/.test(lower) &&
+ /fiber|fibers|fibre|fibres|cytotape/.test(lower)
+ ) {
+ context.task_family = "XRI fibre instance segmentation";
+ } else if (/mitochond/.test(lower)) {
+ context.task_family = "mitochondria instance segmentation";
+ } else if (/synap|cleft/.test(lower)) {
+ context.task_family = "synapse segmentation";
+ } else if (/nuclei|nucleus/.test(lower)) {
+ context.task_family = "nuclei instance segmentation";
+ }
+
+ if (/(fast|quick|smoke|prototype|rough|draft)/.test(lower)) {
+ context.optimization_priority = "speed";
+ } else if (/(accurate|accuracy|quality|best|careful|final)/.test(lower)) {
+ context.optimization_priority = "accuracy";
+ }
+
+ if (/(proofread|review|correct|fix|curat)/.test(lower)) {
+ context.task_goal = "proofreading";
+ } else if (/(segment|segmentation|infer|inference|predict)/.test(lower)) {
+ context.task_goal = "segmentation";
+ } else if (
+ /(retrain|fine.?tune|\btrain(?:ing)?\b(?!\s*\/\s*val))/.test(lower)
+ ) {
+ context.task_goal = "training";
+ } else if (/(compare|metric|evaluate|evaluation|before.*after)/.test(lower)) {
+ context.task_goal = "comparison";
+ }
+
+ if (
+ /(train|val|valid|test)\s*(\/|and|,|\+)\s*(val|valid|test|train)/.test(
+ lower,
+ )
+ ) {
+ context.data_unit = "train/validation/test folders";
+ } else if (
+ /(folder|directory|batch|multiple|several|many|set of|collection)/.test(
+ lower,
+ )
+ ) {
+ context.data_unit = "folder of volumes";
+ } else if (/(one|single)\s+(volume|image|stack|file)/.test(lower)) {
+ context.data_unit = "single volume";
+ } else if (/(tile|tiles|crop|crops|patch|patches)/.test(lower)) {
+ context.data_unit = "tiles or crops";
+ } else if (/(time.?series|timeseries|movie|frames)/.test(lower)) {
+ context.data_unit = "time series";
+ }
+
+ if (/(no labels|unlabeled|no mask|without labels|raw only)/.test(lower)) {
+ context.label_status = "no labels";
+ } else if (/(rough|weak|partial|imperfect).*(label|mask|seg)/.test(lower)) {
+ context.label_status = "rough labels";
+ } else if (
+ /(expert|manual|ground truth|ground-truth|gt).*(label|mask|seg)/.test(lower)
+ ) {
+ context.label_status = "expert labels";
+ } else if (
+ /(prediction|predictions|model output|baseline).*(mask|seg|available|exist)/.test(
+ lower,
+ )
+ ) {
+ context.label_status = "predictions available";
+ }
+
+ const voxelSizeNm = parseVoxelSizeNmFromText(text);
+ if (voxelSizeNm) {
+ context.voxel_size_nm = voxelSizeNm;
+ context.voxel_size_source = "project_description";
+ }
+
+ return context;
+};
+
+const PROJECT_CONTEXT_REQUIRED_FIELDS = [
+ {
+ key: "imaging_modality",
+ label: "imaging modality",
+ question:
+ "What imaging modality is this from, such as EM, fluorescence, or micro-CT?",
+ },
+ {
+ key: "target_structure",
+ label: "target structure",
+ question: "What structure should be segmented or proofread?",
+ },
+ {
+ key: "voxel_size_nm",
+ label: "imaging resolution",
+ question:
+ "What is the voxel size or imaging resolution in z, y, x nanometers?",
+ },
+];
+
+export const evaluateProjectContextCompleteness = (
+ description,
+ options = {},
+) => {
+ const context = {
+ ...(options.defaultContext && typeof options.defaultContext === "object"
+ ? options.defaultContext
+ : {}),
+ ...(inferProjectContextFromDescription(description) || {}),
+ };
+ if (options.useDefaults) {
+ return {
+ complete: true,
+ context: {
+ ...context,
+ use_defaults: true,
+ },
+ missing: [],
+ known: context,
+ next_question: "",
+ };
+ }
+
+ const missingFields = PROJECT_CONTEXT_REQUIRED_FIELDS.filter(
+ (field) => !context[field.key],
+ );
+ const nextQuestion = missingFields[0]?.question || "";
+ return {
+ complete: missingFields.length === 0,
+ context,
+ known: Object.fromEntries(
+ Object.entries(context).filter(([, value]) => Boolean(value)),
+ ),
+ missing: missingFields.map((field) => field.label),
+ next_question: nextQuestion,
+ };
+};
+
+export const describeProjectContextAssessment = (assessment) => {
+ if (!assessment) {
+ return "Describe the dataset so I can choose sensible workflow defaults.";
+ }
+ if (assessment.complete) {
+ return "I have enough project context to continue.";
+ }
+ return (
+ assessment.next_question || "Tell me one more detail about this dataset."
+ );
+};
+
+const preferDirectoryDefault = (profile, role) => {
+ const counts = profile.counts || {};
+ const primaryPaths = profile.schema?.primary_paths || {};
+ const volumeSets = profile.volume_sets || profile.schema?.volume_sets || [];
+
+ if (role === "image" && counts.image > 1 && volumeSets[0]?.image_root) {
+ return volumeSets[0].image_root;
+ }
+ if (
+ (role === "label" || role === "mask") &&
+ counts.label > 1 &&
+ volumeSets[0]?.label_root
+ ) {
+ return volumeSets[0].label_root;
+ }
+ if (counts[role] > 1 && primaryPaths[`${role}_root`]) {
+ return primaryPaths[`${role}_root`];
+ }
+ return "";
+};
+
+export const getProjectRoleDefaultsFromSuggestion = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ const primaryPaths = profile.schema?.primary_paths || {};
+ const examples = profile.examples || {};
+ const paired = profile.paired_examples?.[0] || {};
+ const rootPath = suggestion?.directory_path;
+ const imageRelative =
+ preferDirectoryDefault(profile, "image") ||
+ primaryPaths.image ||
+ paired.image ||
+ examples.image?.[0] ||
+ examples.volume?.[0];
+ const labelRelative =
+ preferDirectoryDefault(profile, "label") ||
+ primaryPaths.label ||
+ primaryPaths.mask ||
+ paired.label ||
+ examples.label?.[0];
+ const predictionRelative =
+ preferDirectoryDefault(profile, "prediction") ||
+ primaryPaths.prediction ||
+ examples.prediction?.[0];
+ const checkpointRelative =
+ primaryPaths.checkpoint || examples.checkpoint?.[0];
+ const configRelative = primaryPaths.config || examples.config?.[0];
+
+ return {
+ image: normalizeProjectRolePath(rootPath, imageRelative),
+ label: normalizeProjectRolePath(rootPath, labelRelative),
+ prediction: normalizeProjectRolePath(rootPath, predictionRelative),
+ checkpoint: normalizeProjectRolePath(rootPath, checkpointRelative),
+ config: normalizeProjectRolePath(rootPath, configRelative),
+ };
+};
+
+export const buildWorkflowPatchFromConfirmedProjectRoles = ({
+ rootPath,
+ roles = {},
+ metadata = null,
+} = {}) => {
+ const labelOrMask = roles.label || roles.mask || "";
+ const patch = {
+ dataset_path: rootPath || null,
+ image_path: normalizeProjectRolePath(rootPath, roles.image) || null,
+ label_path: normalizeProjectRolePath(rootPath, labelOrMask) || null,
+ mask_path:
+ normalizeProjectRolePath(rootPath, roles.mask || labelOrMask) || null,
+ inference_output_path:
+ normalizeProjectRolePath(rootPath, roles.prediction) || null,
+ checkpoint_path:
+ normalizeProjectRolePath(rootPath, roles.checkpoint) || null,
+ config_path: normalizeProjectRolePath(rootPath, roles.config) || null,
+ };
+ if (metadata && typeof metadata === "object") {
+ const nextMetadata = { ...metadata };
+ const voxelSizeNm = normalizeVoxelSizeNm(
+ nextMetadata.project_context?.voxel_size_nm ||
+ nextMetadata.visualization_scales,
+ );
+ if (voxelSizeNm) {
+ nextMetadata.visualization_scales = voxelSizeNm;
+ nextMetadata.visualization_scales_source =
+ nextMetadata.project_context?.voxel_size_source ||
+ nextMetadata.visualization_scales_source ||
+ "project_setup_confirmation";
+ }
+ patch.metadata = nextMetadata;
+ }
+ return Object.fromEntries(
+ Object.entries(patch).filter(([, value]) => Boolean(value)),
+ );
+};
+
+export const buildWorkflowPatchFromProjectSuggestion = (suggestion) => {
+ const rootPath = suggestion?.directory_path;
+ return buildWorkflowPatchFromConfirmedProjectRoles({
+ rootPath,
+ roles: getProjectRoleDefaultsFromSuggestion(suggestion),
+ });
+};
+
+export const summarizeProjectSuggestionForWorkflow = (
+ suggestion,
+ overrides = {},
+) => {
+ const profile = suggestion?.profile || {};
+ const pairedExample = profile.paired_examples?.[0] || null;
+ return {
+ id: suggestion?.id || null,
+ name: suggestion?.name || null,
+ directory_path: suggestion?.directory_path || null,
+ already_mounted: Boolean(suggestion?.already_mounted),
+ mounted_root_id: suggestion?.mounted_root_id || null,
+ recommended: Boolean(suggestion?.recommended),
+ profile: {
+ ready_for_smoke: Boolean(profile.ready_for_smoke),
+ schema: profile.schema || null,
+ counts: profile.counts || {},
+ missing_roles: profile.missing_roles || [],
+ paired_example: pairedExample,
+ role_directories:
+ profile.role_directories || profile.schema?.role_directories || {},
+ volume_sets: getProjectVolumeSetsFromSuggestion(suggestion),
+ },
+ inferred_paths: buildWorkflowPatchFromProjectSuggestion(suggestion),
+ ...overrides,
+ };
+};
+
+const tokenizeGoal = (goal) =>
+ String(goal || "")
+ .toLowerCase()
+ .split(/[^a-z0-9_.-]+/)
+ .map((token) => token.trim())
+ .filter((token) => token.length >= 2);
+
+const flattenSuggestionText = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ const examples = profile.examples || {};
+ const pairedExamples = profile.paired_examples || [];
+ return [
+ suggestion?.id,
+ suggestion?.name,
+ suggestion?.directory_path,
+ suggestion?.description,
+ ...Object.values(examples).flat(),
+ ...pairedExamples.flatMap((pair) => [pair?.image, pair?.label]),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+};
+
+export const scoreProjectSuggestionForGoal = (suggestion, goal) => {
+ const tokens = tokenizeGoal(goal);
+ const profile = suggestion?.profile || {};
+ const counts = profile.counts || {};
+ const haystack = flattenSuggestionText(suggestion);
+
+ if (tokens.length === 0) {
+ return {
+ score: suggestion?.recommended ? 10 : 0,
+ reason: suggestion?.recommended ? "recommended" : "",
+ };
+ }
+
+ let score = 0;
+ const reasons = [];
+ tokens.forEach((token) => {
+ if (haystack.includes(token)) {
+ score += 8;
+ reasons.push(token);
+ }
+ });
+
+ const goalText = tokens.join(" ");
+ const hasImageLabel = counts.image > 0 && counts.label > 0;
+ const hasLoopArtifacts =
+ counts.image > 0 &&
+ counts.label > 0 &&
+ counts.prediction > 0 &&
+ counts.checkpoint > 0;
+
+ if (
+ /(proofread|review|correct|fix|mask|label)/.test(goalText) &&
+ hasImageLabel
+ ) {
+ score += 3;
+ reasons.push("image/mask pair");
+ }
+ if (
+ /(segment|segmentation|predict|prediction|infer|inference)/.test(goalText)
+ ) {
+ if (counts.prediction > 0) {
+ score += 3;
+ reasons.push("prediction artifacts");
+ } else if (counts.image > 0) {
+ score += 2;
+ reasons.push("image data");
+ }
+ }
+ if (/mitochond/.test(goalText) && haystack.includes("mito")) {
+ score += 7;
+ reasons.push("mitochondria");
+ }
+ if (
+ /(train|retrain|compare|metric|baseline|candidate|closed|loop)/.test(
+ goalText,
+ )
+ ) {
+ if (hasLoopArtifacts) {
+ score += 6;
+ reasons.push("loop artifacts");
+ } else if (hasImageLabel) {
+ score += 2;
+ reasons.push("trainable pair");
+ }
+ }
+ if (/(tif|tiff)/.test(goalText) && /\.(ome\.)?tiff?\b/.test(haystack)) {
+ score += 5;
+ reasons.push("TIFF data");
+ }
+ if (/(h5|hdf5)/.test(goalText) && /\.(h5|hdf5)\b/.test(haystack)) {
+ score += 5;
+ reasons.push("HDF5 data");
+ }
+ if (suggestion?.recommended && score > 0) {
+ score += 1;
+ }
+
+ return {
+ score,
+ reason: reasons.slice(0, 3).join(", "),
+ };
+};
+
+export const rankProjectSuggestionsForGoal = (suggestions, goal) =>
+ (suggestions || [])
+ .map((suggestion, originalIndex) => ({
+ ...suggestion,
+ match: scoreProjectSuggestionForGoal(suggestion, goal),
+ originalIndex,
+ }))
+ .sort((a, b) => {
+ if (b.match.score !== a.match.score) {
+ return b.match.score - a.match.score;
+ }
+ if (Boolean(b.recommended) !== Boolean(a.recommended)) {
+ return b.recommended ? 1 : -1;
+ }
+ return a.originalIndex - b.originalIndex;
+ });
+
+export const selectSuggestedProjectIdsForGoal = (suggestions, goal) => {
+ const ranked = rankProjectSuggestionsForGoal(suggestions, goal);
+ if (!ranked.length) return new Set();
+ if (tokenizeGoal(goal).length === 0) {
+ const recommended = ranked.find((suggestion) => suggestion.recommended);
+ return new Set([(recommended || ranked[0]).id]);
+ }
+ return ranked[0].match.score > 0 ? new Set([ranked[0].id]) : new Set();
+};
diff --git a/client/src/utils/projectSuggestions.test.js b/client/src/utils/projectSuggestions.test.js
new file mode 100644
index 00000000..430fdb05
--- /dev/null
+++ b/client/src/utils/projectSuggestions.test.js
@@ -0,0 +1,355 @@
+import {
+ buildWorkflowPatchFromConfirmedProjectRoles,
+ buildWorkflowPatchFromProjectSuggestion,
+ getProjectVolumeSetsFromSuggestion,
+ getProjectRoleDefaultsFromSuggestion,
+ getProjectContextDefaultsFromSuggestion,
+ evaluateProjectContextCompleteness,
+ formatVoxelSizeNm,
+ inferProjectContextFromDescription,
+} from "./projectSuggestions";
+
+const suggestion = {
+ directory_path: "/projects/demo",
+ profile: {
+ schema: {
+ primary_paths: {
+ image: "data/raw.h5",
+ label: "data/mask.h5",
+ prediction: "predictions/baseline.tif",
+ checkpoint: "checkpoints/model.pth.tar",
+ config: "configs/preset.yaml",
+ },
+ },
+ },
+};
+
+const batchSuggestion = {
+ directory_path: "/projects/batch-demo",
+ profile: {
+ counts: {
+ image: 4,
+ label: 4,
+ prediction: 2,
+ config: 1,
+ checkpoint: 1,
+ },
+ volume_sets: [
+ {
+ id: "set-1",
+ name: "train",
+ image_root: "data/Image/train",
+ label_root: "data/Label/train",
+ image_count: 4,
+ label_count: 4,
+ pair_count: 4,
+ },
+ ],
+ schema: {
+ primary_paths: {
+ image: "data/Image/train/img_000.h5",
+ image_root: "data/Image/train",
+ label: "data/Label/train/img_000.h5",
+ label_root: "data/Label/train",
+ prediction: "outputs/baseline.h5",
+ prediction_root: "outputs",
+ checkpoint: "checkpoints/model.pth.tar",
+ config: "configs/preset.yaml",
+ },
+ },
+ },
+};
+
+describe("project suggestion utilities", () => {
+ it("derives absolute default role paths from a profiled project", () => {
+ expect(getProjectRoleDefaultsFromSuggestion(suggestion)).toEqual({
+ image: "/projects/demo/data/raw.h5",
+ label: "/projects/demo/data/mask.h5",
+ prediction: "/projects/demo/predictions/baseline.tif",
+ checkpoint: "/projects/demo/checkpoints/model.pth.tar",
+ config: "/projects/demo/configs/preset.yaml",
+ });
+ });
+
+ it("converts confirmed roles into workflow patch fields", () => {
+ expect(
+ buildWorkflowPatchFromConfirmedProjectRoles({
+ rootPath: "/projects/demo",
+ roles: {
+ image: "/projects/demo/raw.h5",
+ label: "/projects/demo/labels.h5",
+ prediction: "",
+ checkpoint: "/models/model.pth.tar",
+ config: "/projects/demo/config.yaml",
+ },
+ }),
+ ).toEqual({
+ dataset_path: "/projects/demo",
+ image_path: "/projects/demo/raw.h5",
+ label_path: "/projects/demo/labels.h5",
+ mask_path: "/projects/demo/labels.h5",
+ checkpoint_path: "/models/model.pth.tar",
+ config_path: "/projects/demo/config.yaml",
+ });
+ });
+
+ it("copies confirmed imaging resolution into workflow metadata", () => {
+ expect(
+ buildWorkflowPatchFromConfirmedProjectRoles({
+ rootPath: "/projects/demo",
+ roles: {
+ image: "raw.h5",
+ label: "labels.h5",
+ },
+ metadata: {
+ project_context: {
+ voxel_size_nm: [30, 6, 6],
+ voxel_size_source: "project_description",
+ },
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ visualization_scales: [30, 6, 6],
+ visualization_scales_source: "project_description",
+ }),
+ }),
+ );
+ });
+
+ it("keeps the legacy suggestion patch behavior backed by confirmed role defaults", () => {
+ expect(buildWorkflowPatchFromProjectSuggestion(suggestion)).toEqual({
+ dataset_path: "/projects/demo",
+ image_path: "/projects/demo/data/raw.h5",
+ label_path: "/projects/demo/data/mask.h5",
+ mask_path: "/projects/demo/data/mask.h5",
+ inference_output_path: "/projects/demo/predictions/baseline.tif",
+ checkpoint_path: "/projects/demo/checkpoints/model.pth.tar",
+ config_path: "/projects/demo/configs/preset.yaml",
+ });
+ });
+
+ it("prefers project folders when a profile contains multiple volume pairs", () => {
+ expect(getProjectRoleDefaultsFromSuggestion(batchSuggestion)).toEqual({
+ image: "/projects/batch-demo/data/Image/train",
+ label: "/projects/batch-demo/data/Label/train",
+ prediction: "/projects/batch-demo/outputs",
+ checkpoint: "/projects/batch-demo/checkpoints/model.pth.tar",
+ config: "/projects/batch-demo/configs/preset.yaml",
+ });
+ expect(getProjectVolumeSetsFromSuggestion(batchSuggestion)).toEqual([
+ expect.objectContaining({
+ image_root: "data/Image/train",
+ label_root: "data/Label/train",
+ pair_count: 4,
+ }),
+ ]);
+ });
+
+ it("extracts lightweight project context from a biologist description", () => {
+ expect(
+ inferProjectContextFromDescription(
+ "Mouse micro-CT nuclei dataset; segment nuclei at 4 x 4 x 40 nm and prioritize accuracy.",
+ ),
+ ).toEqual({
+ freeform_note:
+ "Mouse micro-CT nuclei dataset; segment nuclei at 4 x 4 x 40 nm and prioritize accuracy.",
+ imaging_modality: "CT",
+ target_structure: "nuclei",
+ task_family: "nuclei instance segmentation",
+ task_goal: "segmentation",
+ optimization_priority: "accuracy",
+ voxel_size_nm: [4, 4, 40],
+ voxel_size_source: "project_description",
+ });
+ });
+
+ it("derives guided defaults for the TapeReader XRI case study", () => {
+ expect(
+ getProjectContextDefaultsFromSuggestion({
+ id: "yixiao-tapereader-xri-case-study",
+ name: "yixiao_tapereader_xri_case_study",
+ directory_path:
+ "/home/weidf/demo_data/yixiao_tapereader_xri_case_study",
+ description: "Yixiao TapeReader XRI fibre segmentation case study.",
+ profile: {
+ counts: { image: 10, label: 8, config: 3 },
+ context_hints: {
+ imaging_modality: "X-ray / XRI volumetric microscopy",
+ target_structure: "CytoTape fibres",
+ voxel_size_nm: [40, 16.3, 16.3],
+ },
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ imaging_modality: "X-ray / XRI volumetric microscopy",
+ target_structure: "CytoTape fibres",
+ task_family: "XRI fibre instance segmentation",
+ mask_status: "mixed: some masks, some image-only volumes",
+ image_only_strategy: "run inference on image-only volumes later",
+ training_policy: "train only on confirmed ground-truth masks",
+ voxel_size_nm: [40, 16.3, 16.3],
+ }),
+ );
+ expect(
+ inferProjectContextFromDescription(
+ "XRI CytoTape fibre masks at 40 x 16.3 x 16.3 nm.",
+ ),
+ ).toEqual(
+ expect.objectContaining({
+ imaging_modality: "X-ray / XRI volumetric microscopy",
+ target_structure: "fibres",
+ task_family: "XRI fibre instance segmentation",
+ voxel_size_nm: [40, 16.3, 16.3],
+ }),
+ );
+ });
+
+ it("parses and formats isotropic imaging resolution", () => {
+ const context = inferProjectContextFromDescription(
+ "EM mitochondria stack; segmentation from one volume; prioritize accuracy; isotropic 5 nm voxels.",
+ );
+ expect(context).toEqual(
+ expect.objectContaining({
+ voxel_size_nm: [5, 5, 5],
+ }),
+ );
+ expect(formatVoxelSizeNm(context.voxel_size_nm)).toBe("5 x 5 x 5 nm");
+ });
+
+ it("does not invent generic defaults from profiled folders", () => {
+ expect(getProjectContextDefaultsFromSuggestion(batchSuggestion)).toEqual(
+ {},
+ );
+ expect(
+ getProjectContextDefaultsFromSuggestion({
+ name: "prepilot_cremi_official",
+ directory_path: "/projects/prepilot_cremi_official",
+ profile: { counts: { image: 3, label: 3 } },
+ }),
+ ).toEqual({});
+ });
+
+ it("uses backend content hints instead of biological name guesses", () => {
+ expect(
+ getProjectContextDefaultsFromSuggestion({
+ name: "opaque-project-name",
+ directory_path: "/projects/opaque-project-name",
+ profile: {
+ counts: { image: 3, label: 3 },
+ context_hints: {
+ imaging_modality: "EM",
+ target_structure: "neurites",
+ task_goal: "proofreading",
+ optimization_priority: "accuracy",
+ voxel_size_nm: [40, 4, 4],
+ },
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ imaging_modality: "EM",
+ target_structure: "neurites",
+ voxel_size_nm: [40, 4, 4],
+ voxel_size_source: "project_profile",
+ }),
+ );
+ });
+
+ it("uses high-confidence audit facts for voxel size defaults", () => {
+ expect(
+ getProjectContextDefaultsFromSuggestion({
+ name: "audit-project",
+ directory_path: "/projects/audit-project",
+ profile: {
+ counts: { image: 1, label: 1 },
+ context_hints: {
+ imaging_modality: "EM",
+ target_structure: "mitochondria",
+ voxel_size_nm: [30, 6, 6],
+ },
+ audit: {
+ context_facts: [
+ {
+ key: "voxel_size_nm",
+ value: [30, 8, 8],
+ source: "volume_metadata:data/image/sample.h5",
+ confidence: "high",
+ },
+ ],
+ },
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ imaging_modality: "EM",
+ target_structure: "mitochondria",
+ voxel_size_nm: [30, 8, 8],
+ voxel_size_source: "volume_metadata:data/image/sample.h5",
+ }),
+ );
+ });
+
+ it("reports missing project context fields deterministically", () => {
+ expect(evaluateProjectContextCompleteness("EM!", {})).toEqual(
+ expect.objectContaining({
+ context: expect.objectContaining({
+ imaging_modality: "EM",
+ }),
+ }),
+ );
+ expect(evaluateProjectContextCompleteness("EM mitochondria", {})).toEqual(
+ expect.objectContaining({
+ complete: false,
+ missing: ["imaging resolution"],
+ next_question:
+ "What is the voxel size or imaging resolution in z, y, x nanometers?",
+ }),
+ );
+ expect(
+ evaluateProjectContextCompleteness(
+ "EM mitochondria segmentation from a single volume at 30 x 6 x 6 nm; prioritize accuracy.",
+ ),
+ ).toEqual(
+ expect.objectContaining({
+ complete: true,
+ context: expect.objectContaining({
+ imaging_modality: "EM",
+ target_structure: "mitochondria",
+ voxel_size_nm: [30, 6, 6],
+ }),
+ }),
+ );
+ });
+
+ it("uses project defaults to avoid blocking on inferable context", () => {
+ expect(
+ evaluateProjectContextCompleteness("EM!", {
+ defaultContext: getProjectContextDefaultsFromSuggestion({
+ name: "opaque-project-name",
+ directory_path: "/projects/opaque-project-name",
+ profile: {
+ counts: { image: 3, label: 3 },
+ context_hints: {
+ target_structure: "neurites",
+ voxel_size_nm: [8, 8, 8],
+ },
+ },
+ }),
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ complete: true,
+ missing: [],
+ context: expect.objectContaining({
+ imaging_modality: "EM",
+ target_structure: "neurites",
+ voxel_size_nm: [8, 8, 8],
+ voxel_size_source: "project_profile",
+ }),
+ }),
+ );
+ });
+});
diff --git a/client/src/views/EHTool.js b/client/src/views/EHTool.js
index 3cb38e32..80f5d508 100644
--- a/client/src/views/EHTool.js
+++ b/client/src/views/EHTool.js
@@ -20,9 +20,11 @@ function EHTool({
// Sync prop changes if they occur (e.g. from parent state update)
useEffect(() => {
- if (savedSessionId && savedSessionId !== sessionId) {
- setSessionId(savedSessionId);
- }
+ setSessionId((currentSessionId) =>
+ savedSessionId && savedSessionId !== currentSessionId
+ ? savedSessionId
+ : currentSessionId,
+ );
}, [savedSessionId]);
// Notify parent when session changes internally
diff --git a/client/src/views/FilesManager.js b/client/src/views/FilesManager.js
index 278f0e33..0daa2a64 100644
--- a/client/src/views/FilesManager.js
+++ b/client/src/views/FilesManager.js
@@ -27,15 +27,36 @@ import {
} from "@ant-design/icons";
import { apiClient } from "../api";
import FileTreeSidebar from "../components/FileTreeSidebar";
-import { openLocalFile, revealInFinder } from "../electronApi";
+import FilePickerModal from "../components/FilePickerModal";
+import {
+ canOpenLocalFile,
+ openLocalFile,
+ revealInFinder,
+} from "../electronApi";
import { AppContext } from "../contexts/GlobalContext";
+import { useWorkflow } from "../contexts/WorkflowContext";
+import { logClientEvent } from "../logging/appEventLog";
+import {
+ buildWorkflowPatchFromConfirmedProjectRoles,
+ describeProjectContextAssessment,
+ evaluateProjectContextCompleteness,
+ formatVoxelSizeNm,
+ getProjectAuditFromSuggestion,
+ getProjectContextDefaultsFromSuggestion,
+ getProjectVolumeSetsFromSuggestion,
+ getProjectRoleDefaultsFromSuggestion,
+} from "../utils/projectSuggestions";
const HIDDEN_SYSTEM_FILES = new Set([
"workflow_preference.json",
".pytc_proofreading.json",
+ ".pytc_instance_labels.tif",
".ds_store",
"thumbs.db",
]);
+const DEFAULT_REMOTE_PROJECT_PATH =
+ process.env.REACT_APP_DEFAULT_PROJECT_PATH ||
+ "/home/weidf/demo_data/yixiao_tapereader_xri_case_study";
const IMAGE_EXTENSIONS = new Set([
".png",
".jpg",
@@ -45,7 +66,614 @@ const IMAGE_EXTENSIONS = new Set([
".tif",
".tiff",
".webp",
+ ".h5",
+ ".hdf5",
+ ".npy",
+ ".npz",
+ ".zarr",
+ ".n5",
+ ".nii",
+ ".nii.gz",
+ ".mrc",
+ ".mrcs",
]);
+const PROJECT_CONFIRMATION_ROLES = [
+ {
+ key: "image",
+ label: "Image data",
+ required: true,
+ placeholder: "/path/to/image-folder-or-volume",
+ },
+ {
+ key: "label",
+ label: "Mask / label data",
+ required: false,
+ placeholder: "/path/to/mask-label-folder-or-file",
+ },
+ {
+ key: "prediction",
+ label: "Existing predictions",
+ required: false,
+ placeholder: "/path/to/prediction-folder-or-file",
+ },
+ {
+ key: "checkpoint",
+ label: "Checkpoint",
+ required: false,
+ placeholder: "/path/to/model-checkpoint",
+ },
+ {
+ key: "config",
+ label: "Config preset",
+ required: false,
+ placeholder: "/path/to/config.yaml",
+ provenanceOnly: true,
+ },
+];
+
+const getSelectedFilePath = (item) => {
+ if (!item) return "";
+ if (item.physical_path) return item.physical_path;
+ if (item.logical_path) return item.logical_path;
+ if (item.path && item.path !== "root" && item.name) {
+ return `${item.path}/${item.name}`;
+ }
+ return item.name || item.path || "";
+};
+
+const stripProjectRoot = (rootPath, path) => {
+ if (!rootPath || !path) return path || "";
+ const normalizedRoot = String(rootPath).replace(/\/+$/, "");
+ const normalizedPath = String(path);
+ return normalizedPath.startsWith(`${normalizedRoot}/`)
+ ? normalizedPath.slice(normalizedRoot.length + 1)
+ : normalizedPath;
+};
+
+const toProjectRelativeRoles = (rootPath, roles = {}) =>
+ Object.fromEntries(
+ Object.entries(roles).map(([key, value]) => [
+ key,
+ stripProjectRoot(rootPath, value),
+ ]),
+ );
+
+const basename = (path) => {
+ const parts = String(path || "")
+ .split(/[\\/]/)
+ .filter(Boolean);
+ return parts[parts.length - 1] || "";
+};
+
+const pluralize = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
+
+const cleanRoleOverridePath = (value) =>
+ String(value || "")
+ .trim()
+ .replace(/^["'`]+|["'`.,;]+$/g, "");
+
+const extractRoleOverridesFromFeedback = (feedback) => {
+ const rolePatterns = [
+ {
+ role: "label",
+ pattern:
+ /\b(?:label|labels|mask|masks|seg|segmentation)\b[^,;\n]*?\b(?:in|at|is|are|to|=)\s+([^,;\n]+)/i,
+ },
+ {
+ role: "image",
+ pattern:
+ /\b(?:image|images|raw|volume|volumes)\b[^,;\n]*?\b(?:in|at|is|are|to|=)\s+([^,;\n]+)/i,
+ },
+ {
+ role: "prediction",
+ pattern:
+ /\b(?:prediction|predictions|output|outputs)\b[^,;\n]*?\b(?:in|at|is|are|to|=)\s+([^,;\n]+)/i,
+ },
+ {
+ role: "checkpoint",
+ pattern:
+ /\b(?:checkpoint|model)\b[^,;\n]*?\b(?:in|at|is|are|to|=)\s+([^,;\n]+)/i,
+ },
+ {
+ role: "config",
+ pattern:
+ /\b(?:config|preset|yaml)\b[^,;\n]*?\b(?:in|at|is|are|to|=)\s+([^,;\n]+)/i,
+ },
+ ];
+
+ return rolePatterns.reduce((acc, { role, pattern }) => {
+ const match = String(feedback || "").match(pattern);
+ if (match?.[1]) {
+ acc[role] = cleanRoleOverridePath(match[1]);
+ }
+ return acc;
+ }, {});
+};
+
+const findVolumeSetForFeedback = (feedback, volumeSets) => {
+ const lower = String(feedback || "").toLowerCase();
+ const asksForSwitch =
+ /\b(use|switch|select|choose|prefer|start with|instead|not train|not val|not test)\b/.test(
+ lower,
+ );
+ if (!asksForSwitch) return null;
+
+ return (volumeSets || []).find((set) => {
+ const candidates = [
+ set.name,
+ basename(set.image_root),
+ basename(set.label_root),
+ set.image_root,
+ set.label_root,
+ ]
+ .filter(Boolean)
+ .map((item) => String(item).toLowerCase());
+ return candidates.some((candidate) => lower.includes(candidate));
+ });
+};
+
+const buildProjectSetupFeedbackResult = (setup) => {
+ const feedback = String(
+ setup?.mappingFeedback ?? setup?.projectContext ?? "",
+ ).trim();
+ const suggestion = setup?.suggestion || {};
+ const volumeSets = getProjectVolumeSetsFromSuggestion(suggestion);
+ const currentRoles = setup?.roles || {};
+ const nextRoles = { ...currentRoles };
+ const appliedChanges = {};
+
+ const selectedSet = findVolumeSetForFeedback(feedback, volumeSets);
+ if (selectedSet) {
+ if (selectedSet.image_root) {
+ nextRoles.image = selectedSet.image_root;
+ appliedChanges.image = selectedSet.image_root;
+ }
+ if (selectedSet.label_root) {
+ nextRoles.label = selectedSet.label_root;
+ appliedChanges.label = selectedSet.label_root;
+ }
+ }
+
+ const explicitOverrides = extractRoleOverridesFromFeedback(feedback);
+ Object.entries(explicitOverrides).forEach(([role, value]) => {
+ if (value) {
+ nextRoles[role] = value;
+ appliedChanges[role] = value;
+ }
+ });
+
+ const isQuestion =
+ /\?/.test(feedback) || /^(what|why|which|how)\b/i.test(feedback);
+ const chosenImage = basename(nextRoles.image);
+ const chosenLabel = basename(nextRoles.label);
+ let response = "";
+
+ if (Object.keys(appliedChanges).length > 0) {
+ response = `Updated the mapping. I will use ${chosenImage || "the selected image data"}${
+ chosenLabel ? ` with ${chosenLabel}` : ""
+ }. Check the fields below before starting.`;
+ } else if (
+ isQuestion &&
+ chosenImage &&
+ chosenLabel &&
+ chosenImage === chosenLabel
+ ) {
+ response = `${chosenImage} appears twice because the image and label folders use the same split name. I mapped image data to the image-side ${chosenImage} folder and masks to the label-side ${chosenLabel} folder.`;
+ } else if (isQuestion) {
+ response =
+ "I treated this as a question, so I did not change the mapping. To change it, say something like “use val split” or edit the fields below.";
+ } else {
+ response =
+ "I recorded that context. I did not see a path correction, so the mapping is unchanged.";
+ }
+
+ return {
+ feedback,
+ response,
+ nextRoles,
+ appliedChanges,
+ };
+};
+
+const PROJECT_CONTEXT_LABELS = {
+ imaging_modality: "Imaging modality",
+ target_structure: "Target structure",
+ voxel_size_nm: "Voxel size (z, y, x)",
+ task_family: "Task family",
+ training_policy: "Training policy",
+ volume_split: "Volume split",
+};
+
+const EDITABLE_PROJECT_CONTEXT_FIELDS = [
+ {
+ key: "imaging_modality",
+ label: "Imaging modality",
+ placeholder: "electron microscopy, fluorescence, micro-CT",
+ },
+ {
+ key: "target_structure",
+ label: "Target structure",
+ placeholder: "mitochondria, nuclei, membranes",
+ },
+ {
+ key: "voxel_size_nm",
+ label: "Voxel size (z, y, x nm)",
+ placeholder: "30, 6, 6",
+ },
+];
+
+const normalizeVolumeSplitCount = (value) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
+};
+
+const formatVolumeSplitText = (summary) => {
+ if (!summary) return "";
+ const groundTruth = normalizeVolumeSplitCount(summary.ground_truth);
+ const needsProofreading = normalizeVolumeSplitCount(
+ summary.needs_proofreading,
+ );
+ const missingSegmentation = normalizeVolumeSplitCount(
+ summary.missing_segmentation,
+ );
+ if (
+ groundTruth === null ||
+ needsProofreading === null ||
+ missingSegmentation === null
+ ) {
+ return "";
+ }
+ return `${groundTruth} ground-truth / ${needsProofreading} draft masks / ${missingSegmentation} image-only`;
+};
+
+const parseVolumeSplitFromMaskStatusText = (text) => {
+ const source = String(text || "").toLowerCase();
+ const groundTruth = source.match(
+ /(\d+)\s*(?:ground-truth|ground truth)\s*(?:mask|masks)?/,
+ );
+ const needsProofreading = source.match(
+ /(\d+)\s*(?:draft|proofread|needs proofreading|needs-proofreading)/,
+ );
+ const imageOnly = source.match(
+ /(\d+)\s*(?:image-only|no segmentation|missing segmentation)/,
+ );
+ if (!groundTruth && !needsProofreading && !imageOnly) {
+ return "";
+ }
+ const parsed = {
+ ground_truth: normalizeVolumeSplitCount(groundTruth?.[1]),
+ needs_proofreading: normalizeVolumeSplitCount(needsProofreading?.[1]),
+ missing_segmentation: normalizeVolumeSplitCount(imageOnly?.[1]),
+ };
+ return formatVolumeSplitText(parsed);
+};
+
+const getProjectVolumeSplitFromSuggestion = (suggestion) => {
+ const profile = suggestion?.profile || {};
+ const candidates = [
+ profile.schema?.manifest?.initial_progress_summary,
+ profile.manifest?.initial_progress_summary,
+ ].filter(Boolean);
+ const splitFromSummary = candidates.map(formatVolumeSplitText).find(Boolean);
+ if (splitFromSummary) {
+ return splitFromSummary;
+ }
+ const maskHints = [
+ profile.context_hints?.mask_status,
+ profile.schema?.context_hints?.mask_status,
+ ].filter(Boolean);
+ const splitFromMasks = maskHints
+ .map(parseVolumeSplitFromMaskStatusText)
+ .find(Boolean);
+ if (splitFromMasks) {
+ return splitFromMasks;
+ }
+ return "";
+};
+
+const parseEditableVoxelSizeNm = (value) => {
+ if (Array.isArray(value)) {
+ const numeric = value.slice(0, 3).map((item) => Number(item));
+ return numeric.length === 3 &&
+ numeric.every((item) => Number.isFinite(item) && item > 0)
+ ? numeric
+ : null;
+ }
+ const values = String(value || "").match(/\d+(?:\.\d+)?/g);
+ if (!values || values.length < 3) return null;
+ const numeric = values.slice(0, 3).map((item) => Number(item));
+ return numeric.every((item) => Number.isFinite(item) && item > 0)
+ ? numeric
+ : null;
+};
+
+const formatEditableVoxelSize = (value) => {
+ const parsed = parseEditableVoxelSizeNm(value);
+ return parsed ? parsed.join(", ") : String(value || "");
+};
+
+const cleanEditableProjectContext = (context, fallbackDescription = "") => {
+ const source = context && typeof context === "object" ? context : {};
+ const next = {};
+ const freeformNote = String(
+ source.freeform_note || fallbackDescription || "",
+ ).trim();
+ if (freeformNote) {
+ next.freeform_note = freeformNote.slice(0, 1000);
+ }
+ ["imaging_modality", "target_structure"].forEach((key) => {
+ const value = String(source[key] || "").trim();
+ if (value) next[key] = value;
+ });
+ [
+ "task_family",
+ "mask_status",
+ "image_only_strategy",
+ "training_policy",
+ "volume_split",
+ ].forEach((key) => {
+ const value = String(source[key] || "").trim();
+ if (value) next[key] = value;
+ });
+ const voxelSizeNm = parseEditableVoxelSizeNm(source.voxel_size_nm);
+ if (voxelSizeNm) {
+ next.voxel_size_nm = voxelSizeNm;
+ next.voxel_size_source =
+ source.voxel_size_source || "project_setup_confirmation";
+ }
+ if (source.use_defaults) {
+ next.use_defaults = true;
+ }
+ return next;
+};
+
+const editableProjectContextMissingFields = (context) => {
+ const missing = [];
+ if (!context?.imaging_modality) missing.push("imaging modality");
+ if (!context?.target_structure) missing.push("target structure");
+ if (!parseEditableVoxelSizeNm(context?.voxel_size_nm)) {
+ missing.push("imaging resolution");
+ }
+ return missing;
+};
+
+const buildProjectContextMetadata = (
+ description,
+ _existingMetadata = {},
+ options = {},
+) => {
+ void _existingMetadata;
+ const assessment = evaluateProjectContextCompleteness(description, options);
+ const contextSource =
+ options.projectContextDraft &&
+ typeof options.projectContextDraft === "object"
+ ? options.projectContextDraft
+ : assessment.context;
+ const projectContext = cleanEditableProjectContext(
+ contextSource,
+ description,
+ );
+ if (!projectContext || Object.keys(projectContext).length === 0) return null;
+ const missing = editableProjectContextMissingFields(projectContext);
+ return {
+ ...projectContext,
+ completeness: {
+ complete: missing.length === 0,
+ missing,
+ },
+ source: "project_setup_confirmation",
+ updated_at: new Date().toISOString(),
+ };
+};
+
+const getProjectContextDraftWithSharedFacts = (
+ setup,
+ descriptionOverride = "",
+ context = null,
+) => {
+ const baseContext = cleanEditableProjectContext(
+ context || {},
+ descriptionOverride,
+ );
+ const splitText = getProjectVolumeSplitFromSuggestion(setup?.suggestion);
+ if (splitText && !baseContext.volume_split) {
+ baseContext.volume_split = splitText;
+ }
+ return baseContext;
+};
+
+const compactProjectPath = (pathValue) => {
+ const text = String(pathValue || "").trim();
+ if (!text) return "";
+ const normalized = text.replace(/\\/g, "/");
+ const parts = normalized.split("/").filter(Boolean);
+ if (parts.length <= 3) return normalized;
+ return parts.slice(-3).join("/");
+};
+
+const formatAuditFactValue = (fact) => {
+ if (!fact) return "";
+ if (fact.key === "voxel_size_nm") {
+ return formatVoxelSizeNm(fact.value);
+ }
+ if (Array.isArray(fact.value)) {
+ return fact.value.join(", ");
+ }
+ return String(fact.value ?? "");
+};
+
+const compactAuditSource = (source) => {
+ const text = String(source || "").trim();
+ if (!text) return "";
+ return text
+ .replace(/^volume_metadata:/, "")
+ .replace(/^content_spot_check:/, "");
+};
+
+const auditFindingStyles = {
+ error: {
+ background: "#fff1f0",
+ border: "#ffccc7",
+ color: "#a8071a",
+ },
+ warning: {
+ background: "#fff7e6",
+ border: "#ffd591",
+ color: "#ad6800",
+ },
+ info: {
+ background: "#f6ffed",
+ border: "#b7eb8f",
+ color: "#237804",
+ },
+};
+
+const buildProjectBrief = ({ context, roles, suggestion, volumeSets }) => {
+ const projectContext = context || {};
+ const target = String(projectContext.target_structure || "").trim();
+ const modality = String(projectContext.imaging_modality || "").trim();
+ const resolution = formatVoxelSizeNm(projectContext.voxel_size_nm);
+ const imagePath = compactProjectPath(roles?.image);
+ const labelPath = compactProjectPath(roles?.label || roles?.mask);
+ const configPath = compactProjectPath(roles?.config);
+ const predictionPath = compactProjectPath(roles?.prediction);
+ const pairCounts = (volumeSets || []).map((set) =>
+ Number(set.pair_count || 0),
+ );
+ const pairCount = pairCounts.length > 0 ? Math.max(...pairCounts) : 0;
+ const subject = [modality, target].filter(Boolean).join(" ");
+ const summary = subject
+ ? `${subject} project${resolution ? ` at ${resolution}` : ""}.`
+ : `Project${resolution ? ` at ${resolution}` : ""}.`;
+ const nextMoves = [
+ imagePath ? `Use ${imagePath} as the active image data.` : "",
+ labelPath
+ ? `Use ${labelPath} for masks, proofreading seeds, and evaluation.`
+ : "Start image-only; ask before training or metric comparison.",
+ resolution
+ ? `Use ${resolution} as the z/y/x voxel size for visualization and model defaults.`
+ : "",
+ configPath ? `Start from ${configPath} as the config preset.` : "",
+ predictionPath ? `Treat ${predictionPath} as the existing prediction.` : "",
+ pairCount > 1
+ ? `Keep this as a multi-volume project with ${pairCount} detected image/mask pairs.`
+ : "",
+ ].filter(Boolean);
+ const uncertainties = [];
+ if (projectContext.completeness?.complete === false) {
+ uncertainties.push(
+ `Context still missing: ${projectContext.completeness.missing.join(", ")}.`,
+ );
+ }
+ if (!labelPath) {
+ uncertainties.push("No mask/label path is confirmed yet.");
+ }
+ if (!configPath) {
+ uncertainties.push("No config preset is confirmed yet.");
+ }
+ return {
+ project_name: suggestion?.name || "Mounted project",
+ summary,
+ fields: Object.entries(PROJECT_CONTEXT_LABELS)
+ .map(([key, label]) => ({
+ key,
+ label,
+ value:
+ key === "voxel_size_nm"
+ ? formatVoxelSizeNm(projectContext[key])
+ : projectContext[key],
+ }))
+ .filter((item) => Boolean(item.value)),
+ next_moves: nextMoves,
+ uncertainties,
+ };
+};
+
+const buildProjectMemoryProfile = ({
+ suggestion,
+ roles,
+ workflowPatch,
+ projectDescription,
+ projectContext,
+ projectBrief,
+ projectAudit,
+ semanticTurns,
+ mappingTurns,
+ source,
+ existingProfile,
+}) => {
+ const now = new Date().toISOString();
+ return {
+ ...(existingProfile && typeof existingProfile === "object"
+ ? existingProfile
+ : {}),
+ schema_version: "pytc-project-context/v1",
+ project_name: suggestion?.name || "Mounted project",
+ project_directory: suggestion?.directory_path || null,
+ semantic_context: projectContext || {
+ freeform_note: String(projectDescription || "").trim(),
+ },
+ project_brief: projectBrief || null,
+ project_audit: projectAudit || null,
+ context_facts: projectAudit?.context_facts || [],
+ mechanistic_mapping: roles || {},
+ workflow_memory: {
+ current_stage: "setup",
+ last_completed_step: "project_setup_confirmed",
+ known_blockers: [],
+ agent_recommendation: "Start from the confirmed project data.",
+ workflow_patch_keys: Object.keys(workflowPatch || {}),
+ },
+ setup_turns: {
+ semantic: semanticTurns || [],
+ mapping: mappingTurns || [],
+ },
+ source,
+ updated_at: now,
+ };
+};
+
+const GUIDED_PROJECT_CONTEXT_GROUPS = [
+ {
+ key: "task_family",
+ label: "What are we working over?",
+ helper:
+ "Example: XRI fibre instance segmentation, mitochondria segmentation, proofreading masks",
+ placeholder: "XRI fibre instance segmentation",
+ },
+ {
+ key: "mask_status",
+ label: "What mask data exists?",
+ helper:
+ "Example: 6 ground-truth masks, 2 draft masks, 2 image-only volumes",
+ placeholder:
+ "mixed: 6 ground-truth masks, 2 draft masks, 2 image-only targets",
+ },
+ {
+ key: "image_only_strategy",
+ label: "What should happen to image-only volumes?",
+ helper: "Tell the agent whether to infer, ignore, or ask first.",
+ placeholder: "run inference on image-only volumes later",
+ },
+ {
+ key: "training_policy",
+ label: "Which masks are safe for training?",
+ helper: "Example: train only on confirmed ground-truth masks",
+ placeholder: "train only on confirmed ground-truth masks",
+ },
+];
+
+const appendProjectContextAnswer = (existingText, nextAnswer) => {
+ const existing = String(existingText || "").trim();
+ const next = String(nextAnswer || "").trim();
+ if (!next) return existing;
+ if (!existing) return next;
+ const existingLower = existing.toLowerCase();
+ const nextLower = next.toLowerCase();
+ if (nextLower.includes(existingLower)) return next;
+ if (existingLower.includes(nextLower)) return existing;
+ return `${existing}\n${next}`;
+};
const collectDescendantFolderIds = (folderList, rootIds) => {
const removed = new Set();
@@ -70,7 +698,8 @@ const isFolderWithinSubtree = (folderList, rootId, targetId) => {
return false;
}
- let current = folderList.find((folder) => folder.key === targetId);
+ const folderByKey = new Map(folderList.map((folder) => [folder.key, folder]));
+ let current = folderByKey.get(targetId);
while (current) {
if (current.key === rootId) {
return true;
@@ -78,7 +707,7 @@ const isFolderWithinSubtree = (folderList, rootId, targetId) => {
if (!current.parent || current.parent === "root") {
break;
}
- current = folderList.find((folder) => folder.key === current.parent);
+ current = folderByKey.get(current.parent);
}
return false;
};
@@ -118,11 +747,13 @@ const transformFiles = (fileList) => {
function FilesManager() {
const context = useContext(AppContext);
+ const workflowContext = useWorkflow();
const [folders, setFolders] = useState([]);
const [files, setFiles] = useState({});
const foldersRef = useRef([]);
const filesRef = useRef({});
const [currentFolder, setCurrentFolder] = useState("root");
+ const currentFolderRef = useRef("root");
const [loadedParents, setLoadedParents] = useState([]);
const [loadingParents, setLoadingParents] = useState([]);
const loadedParentsRef = useRef(new Set());
@@ -142,9 +773,24 @@ function FilesManager() {
const [serverUnavailable, setServerUnavailable] = useState(false);
const [hasShownServerWarning, setHasShownServerWarning] = useState(false);
const [previewStatus, setPreviewStatus] = useState({});
+ const [projectSuggestions, setProjectSuggestions] = useState([]);
+ const [pendingProjectSetup, setPendingProjectSetup] = useState(null);
+ const [projectSetupSaving, setProjectSetupSaving] = useState(false);
+ const [projectSetupFeedbackSaving, setProjectSetupFeedbackSaving] =
+ useState(false);
+ const [rolePickerTarget, setRolePickerTarget] = useState(null);
+ const folderByKey = React.useMemo(
+ () => new Map(folders.map((folder) => [folder.key, folder])),
+ [folders],
+ );
+ const pendingProjectSetupRef = useRef(null);
+ const autoProjectSetupOpenedRef = useRef(false);
+ const handledChooseDataActionRef = useRef(null);
+ const chooseProjectDataRef = useRef(null);
const containerRef = useRef(null);
const itemRefs = useRef({});
const isDragSelecting = useRef(false);
+ const keyHandlerStateRef = useRef({});
const previewBaseUrl = apiClient.defaults.baseURL || "http://localhost:4242";
// Sidebar Resize Logic
@@ -152,6 +798,10 @@ function FilesManager() {
const [isResizing, setIsResizing] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
+ useEffect(() => {
+ pendingProjectSetupRef.current = pendingProjectSetup;
+ }, [pendingProjectSetup]);
+
const startResizing = React.useCallback(() => setIsResizing(true), []);
const stopResizing = React.useCallback(() => setIsResizing(false), []);
const resize = React.useCallback(
@@ -192,6 +842,10 @@ function FilesManager() {
filesRef.current = files;
}, [files]);
+ useEffect(() => {
+ currentFolderRef.current = currentFolder;
+ }, [currentFolder]);
+
const syncLoadedParents = React.useCallback((nextParents) => {
loadedParentsRef.current = new Set(nextParents);
setLoadedParents(nextParents);
@@ -240,11 +894,14 @@ function FilesManager() {
const normalizedParent = String(parentKey || "root");
const previousFolders = foldersRef.current;
const previousFiles = filesRef.current;
- const { folders: nextFolders, files: nextFiles } = transformFiles(fileList);
+ const { folders: nextFolders, files: nextFiles } =
+ transformFiles(fileList);
const existingDirectChildIds = previousFolders
.filter((folder) => folder.parent === normalizedParent)
.map((folder) => folder.key);
- const nextDirectChildIds = new Set(nextFolders.map((folder) => folder.key));
+ const nextDirectChildIds = new Set(
+ nextFolders.map((folder) => folder.key),
+ );
const removedFolderIds = collectDescendantFolderIds(
previousFolders,
existingDirectChildIds.filter((id) => !nextDirectChildIds.has(id)),
@@ -253,14 +910,19 @@ function FilesManager() {
const mergedFolders = [
...previousFolders.filter(
(folder) =>
- folder.parent !== normalizedParent && !removedFolderIds.has(folder.key),
+ folder.parent !== normalizedParent &&
+ !removedFolderIds.has(folder.key),
),
...nextFolders,
].sort((left, right) => {
if (left.parent === right.parent) {
- return String(left.title || "").localeCompare(String(right.title || ""));
+ return String(left.title || "").localeCompare(
+ String(right.title || ""),
+ );
}
- return String(left.parent || "").localeCompare(String(right.parent || ""));
+ return String(left.parent || "").localeCompare(
+ String(right.parent || ""),
+ );
});
const mergedFiles = { ...previousFiles };
@@ -277,6 +939,31 @@ function FilesManager() {
setFolders(mergedFolders);
setFiles(mergedFiles);
markParentLoaded(normalizedParent);
+
+ const activeFolder = currentFolderRef.current;
+ if (
+ activeFolder !== "root" &&
+ !mergedFolders.some((folder) => folder.key === activeFolder)
+ ) {
+ const fallbackFolder =
+ normalizedParent !== activeFolder &&
+ mergedFolders.some((folder) => folder.key === normalizedParent)
+ ? normalizedParent
+ : "root";
+ setCurrentFolder(fallbackFolder);
+ setSelectedItems([]);
+ setEditingItem(null);
+ setNewItemType(null);
+ logClientEvent("files_active_folder_reconciled", {
+ source: "files_manager",
+ message: "Active folder was removed by a fresh file index load.",
+ data: {
+ previousFolder: activeFolder,
+ fallbackFolder,
+ refreshedParent: normalizedParent,
+ },
+ });
+ }
},
[markParentLoaded],
);
@@ -347,6 +1034,38 @@ function FilesManager() {
return res.data;
} catch (err) {
const isNetworkError = !err.response;
+ const isMissingParent =
+ err.response?.status === 404 && normalizedParent !== "root";
+ if (isMissingParent) {
+ logClientEvent("files_parent_missing_refreshed", {
+ level: "WARN",
+ source: "files_manager",
+ message:
+ "File index parent was stale; refreshing the root project tree.",
+ data: {
+ parent: normalizedParent,
+ activeFolder: currentFolderRef.current,
+ detail: err.response?.data?.detail || err.message,
+ },
+ });
+ const activeFolderWasStale =
+ currentFolderRef.current === normalizedParent ||
+ isFolderWithinSubtree(
+ foldersRef.current,
+ normalizedParent,
+ currentFolderRef.current,
+ );
+ removeFolderSubtrees([normalizedParent]);
+ if (activeFolderWasStale) {
+ setCurrentFolder("root");
+ setSelectedItems([]);
+ }
+ await fetchFolderContents("root", {
+ force: true,
+ silentNetworkError: true,
+ });
+ return null;
+ }
if (isNetworkError) {
setServerUnavailable(true);
if (!hasShownServerWarning && !silentNetworkError) {
@@ -365,6 +1084,7 @@ function FilesManager() {
[
hasShownServerWarning,
replaceFolderChildren,
+ removeFolderSubtrees,
setParentLoadingState,
],
);
@@ -375,9 +1095,9 @@ function FilesManager() {
const loadVisibleFolders = async () => {
if (!isMounted) return;
- await fetchFolderContents("root");
+ await fetchFolderContents("root", { force: true });
if (currentFolder !== "root") {
- await fetchFolderContents(currentFolder);
+ await fetchFolderContents(currentFolder, { force: true });
}
};
@@ -404,9 +1124,31 @@ function FilesManager() {
};
}, [currentFolder, fetchFolderContents, serverUnavailable]); // Retry when server unavailable
- const getCurrentFolderObj = () =>
- folders.find((f) => f.key === currentFolder);
- // eslint-disable-next-line no-loop-func
+ useEffect(() => {
+ const refreshVisibleFolders = () => {
+ if (document.visibilityState === "hidden") return;
+ fetchFolderContents("root", {
+ force: true,
+ silentNetworkError: true,
+ });
+ const activeFolder = currentFolderRef.current;
+ if (activeFolder && activeFolder !== "root") {
+ fetchFolderContents(activeFolder, {
+ force: true,
+ silentNetworkError: true,
+ });
+ }
+ };
+
+ window.addEventListener("focus", refreshVisibleFolders);
+ document.addEventListener("visibilitychange", refreshVisibleFolders);
+ return () => {
+ window.removeEventListener("focus", refreshVisibleFolders);
+ document.removeEventListener("visibilitychange", refreshVisibleFolders);
+ };
+ }, [fetchFolderContents]);
+
+ const getCurrentFolderObj = () => folderByKey.get(currentFolder);
const getBreadcrumbs = () => {
const path = [{ key: "root", title: "Projects", parent: null }];
if (currentFolder === "root") {
@@ -417,7 +1159,7 @@ function FilesManager() {
while (curr) {
chain.unshift(curr);
if (!curr.parent || curr.parent === "root") break;
- curr = folders.find((f) => f.key === curr.parent);
+ curr = folderByKey.get(curr.parent);
}
return path.concat(chain);
};
@@ -435,13 +1177,32 @@ function FilesManager() {
setNewItemType(null);
};
+ const loadProjectSuggestions = React.useCallback(async () => {
+ try {
+ const response = await apiClient.get("/files/project-suggestions", {
+ withCredentials: true,
+ });
+ const suggestions = response.data || [];
+ setProjectSuggestions(suggestions);
+ return suggestions;
+ } catch (error) {
+ // Suggestions are convenience only; normal mounting must keep working.
+ console.warn("Could not load project suggestions", error);
+ setProjectSuggestions([]);
+ return [];
+ }
+ }, []);
+
+ useEffect(() => {
+ loadProjectSuggestions();
+ }, [loadProjectSuggestions]);
+
const isImageFile = (file) => {
if (!file || file.is_folder) return false;
if (file.type && file.type.startsWith("image/")) return true;
- const ext = `.${String(file.name || "")
- .split(".")
- .pop()}`.toLowerCase();
- return IMAGE_EXTENSIONS.has(ext);
+ const name = String(file.name || "").toLowerCase();
+ const ext = `.${name.split(".").pop()}`;
+ return IMAGE_EXTENSIONS.has(ext) || name.endsWith(".nii.gz");
};
const getPreviewUrl = (fileKey) =>
@@ -769,7 +1530,7 @@ function FilesManager() {
const childFiles = files[parentId] || [];
descendantFileCount += childFiles.length;
- childFiles.forEach((f) => {
+ for (const f of childFiles) {
const sizeStr = String(f.size || "");
const match = sizeStr.match(/([0-9.]+)\s*(KB|MB|GB|B)/i);
if (match) {
@@ -780,7 +1541,7 @@ function FilesManager() {
if (unit === "MB") descendantSizeKb += value * 1024;
if (unit === "GB") descendantSizeKb += value * 1024 * 1024;
}
- });
+ }
}
}
@@ -1028,13 +1789,34 @@ function FilesManager() {
const handleMouseUp = () => setSelectionBox(null);
+ useEffect(() => {
+ keyHandlerStateRef.current = {
+ clipboard,
+ currentFolder,
+ editingItem,
+ files,
+ finishCreateFolder,
+ finishRename,
+ folders,
+ handleCopy,
+ handleCut,
+ handleDelete,
+ handlePaste,
+ newItemType,
+ selectedItems,
+ tempName,
+ };
+ });
+
// Keyboard shortcuts
- // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const handleKeyDown = (e) => {
- if (editingItem) {
+ const state = keyHandlerStateRef.current;
+ if (state.editingItem) {
if (e.key === "Enter")
- newItemType ? finishCreateFolder() : finishRename();
+ state.newItemType
+ ? state.finishCreateFolder?.()
+ : state.finishRename?.();
if (e.key === "Escape") {
setEditingItem(null);
setNewItemType(null);
@@ -1049,33 +1831,24 @@ function FilesManager() {
target.isContentEditable)
)
return;
- if (e.key === "Delete") handleDelete();
- if (e.ctrlKey && e.key === "c") handleCopy();
- if (e.ctrlKey && e.key === "x") handleCut();
- if (e.ctrlKey && e.key === "v") handlePaste();
+ if (e.key === "Delete") state.handleDelete?.();
+ if (e.ctrlKey && e.key === "c") state.handleCopy?.();
+ if (e.ctrlKey && e.key === "x") state.handleCut?.();
+ if (e.ctrlKey && e.key === "v") state.handlePaste?.();
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
const allKeys = [
- ...folders
- .filter((f) => f.parent === currentFolder)
+ ...(state.folders || [])
+ .filter((f) => f.parent === state.currentFolder)
.map((f) => f.key),
- ...(files[currentFolder] || []).map((f) => f.key),
+ ...((state.files || {})[state.currentFolder] || []).map((f) => f.key),
];
setSelectedItems(allKeys);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [
- selectedItems,
- clipboard,
- currentFolder,
- folders,
- files,
- editingItem,
- newItemType,
- tempName,
- ]);
+ }, []);
// Context menu handling
const handleContextMenu = (e, type, key) => {
@@ -1140,7 +1913,12 @@ function FilesManager() {
)}
) : type === "folder" ? (
-