From 8c089c2ed4c648da442d8de8041ffeae839b046d Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Thu, 4 Jun 2026 18:19:35 -0700 Subject: [PATCH] feat: harmonize the edit-save UX with the upgrade feature (v2 editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models an edit as an upgrade-to-a-hand-edited-version so the v2 save flow reuses the upgrade feature's own machinery end-to-end. - `TaskDetails.resolveSaveAction` builds an `UpgradeCandidate` via the upgrade flow's `buildUpgradeCandidateFromResolved(...)` instead of an ad-hoc diff — giving the same `inputDiff`/`outputDiff` **and `predictedIssues`** (missing required inputs, lost connections) the upgrade panel computes. - The v2 save modal now renders the shared `ComponentEditSummary` (current → new digest + breaking-change warning) and the upgrade panel's `PredictedIssuesSection` (now exported), so an edit shows the exact same diagnostics as an upgrade. - **Edit preview overlay:** while the modal is open, `useUpgradePreviewOverlay` shows the edited component as a ghost-diff on the selected node (others faded) — identical to the upgrade preview — driven by a transient `previewCandidate` state that clears when the modal closes/cancels. - "Update" continues to apply via the upgrade flow's `replaceTask` (already shared). Analytics: `pipeline_editor.component.edited` on update/place. `PredictedIssuesSection` is exported from `UpgradeCandidateDetail` (kept in the v2 upgrade module since it depends on the v2 `ValidationIssue` model type). --- .../components/UpgradeCandidateDetail.tsx | 6 ++- .../context/TaskDetails/TaskDetails.tsx | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx b/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx index a4618e4da..42c1211ab 100644 --- a/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx +++ b/src/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail.tsx @@ -28,7 +28,11 @@ function EmptyDetail() { ); } -function PredictedIssuesSection({ issues }: { issues: ValidationIssue[] }) { +export function PredictedIssuesSection({ + issues, +}: { + issues: ValidationIssue[]; +}) { if (issues.length === 0) return null; return ( diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx index 3e3133acb..fc3214cb9 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx @@ -16,6 +16,8 @@ import { Heading, Text } from "@/components/ui/typography"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { AnnotationsBlock } from "@/routes/v2/pages/Editor/components/AnnotationsBlock/AnnotationsBlock"; +import { PredictedIssuesSection } from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail"; +import { buildUpgradeCandidateFromResolved } from "@/routes/v2/pages/Editor/components/UpgradeComponents/utils/buildUpgradeCandidateFromResolved"; import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; @@ -26,7 +28,7 @@ import { ZINDEX_ANNOTATION, } from "@/utils/annotations"; import type { HydratedComponentReference } from "@/utils/componentSpec"; -import { diffComponentIO } from "@/utils/componentSpecDiff"; +import { componentMetadata } from "@/utils/componentTracking"; import { DEFAULT_NODE_DIMENSIONS } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; @@ -92,19 +94,29 @@ export const TaskDetails = observer(function TaskDetails({ hydratedComponent: HydratedComponentReference; onChoose: (action: "update" | "import" | "place") => void; }) => { - const { inputDiff, outputDiff } = diffComponentIO< - { name: string; type?: unknown }, - { name: string; type?: unknown } - >(task.resolvedComponentSpec, hydratedComponent.spec); + // Model the edit as an upgrade candidate so we reuse the upgrade flow's + // diff + predicted-issues computation in the choose-action view. + const candidate = buildUpgradeCandidateFromResolved( + task.$id, + task.name, + task.componentRef.digest ?? "", + task.resolvedComponentSpec, + hydratedComponent, + spec, + ); return ( + > + + ); }; @@ -112,6 +124,12 @@ export const TaskDetails = observer(function TaskDetails({ const result = replaceTask(spec, task.$id, hydratedComponent); const lostInputs = result.inputDiff?.lostEntities ?? []; + track("pipeline_editor.component.edited", { + ...componentMetadata(hydratedComponent, "user"), + action: "update", + lost_inputs_count: lostInputs.length, + }); + if (lostInputs.length > 0) { const inputNames = lostInputs.map((input) => input.name).join(", "); notify( @@ -153,6 +171,12 @@ export const TaskDetails = observer(function TaskDetails({ }); const newTask = addTask(spec, hydratedComponent, position); + + track("pipeline_editor.component.edited", { + ...componentMetadata(hydratedComponent, "user"), + action: "place", + }); + notify("Task added", "success"); // Reveal the new node: animate the viewport to it, then spotlight it.