From 93b4ee1d2195392089f3e9fef5362cfd1c384f8b Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Thu, 4 Jun 2026 16:43:32 -0700 Subject: [PATCH] feat: choose-action modal on Save in the v2 component editor Brings the v2 editor's "Edit Component" Save flow to parity with the legacy editor: instead of silently updating the selected task in place, it opens a small modal to choose what to do with the edit (Update this task / Import to library), showing which inputs/outputs changed. - `ChooseSaveActionDialog` (v2) is opened via the `DialogProvider` (`useDialog().open`), mirroring `ReplaceConfirmationDialog`, and resolves to a shared `SaveAction`. It reuses the shared `DiffSection`. - `TaskDetails` builds `resolveSaveAction` (diffing via the shared `diffComponentIO`, mapping a cancelled dialog to `"cancel"` via `convertCancelErrorTo`) and gates `handleComponentSaved` on `"update"`, applying it through the existing `replaceTask` store action. `ComponentRefBar` forwards `resolveSaveAction` to the editor dialog. The "Place as a new task" option is gated behind `allowPlace`, enabled in a later branch. No behavior changes when no resolver is supplied. --- .../context/TaskDetails/TaskDetails.tsx | 32 +++++++++++++++++++ .../components/ComponentRefBar.tsx | 10 +++++- 2 files changed, 41 insertions(+), 1 deletion(-) 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 9dcdd8704..f6cacf383 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 @@ -1,6 +1,8 @@ import { observer } from "mobx-react-lite"; import { useEffect, useRef, useState } from "react"; +import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction"; +import { SaveActionsView } from "@/components/shared/ComponentEditor/SaveActionsView"; import { StackingControls } from "@/components/shared/ReactFlow/FlowControls/StackingControls"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -18,6 +20,7 @@ import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { SYSTEM_ANNOTATIONS, ZINDEX_ANNOTATION } from "@/utils/annotations"; import type { HydratedComponentReference } from "@/utils/componentSpec"; +import { diffComponentIO } from "@/utils/componentSpecDiff"; import { tracking } from "@/utils/tracking"; import { getTaskYamlText } from "./components/actions/getTaskYamlText"; @@ -75,9 +78,37 @@ export const TaskDetails = observer(function TaskDetails({ const isSubgraphTask = task.subgraphSpec !== undefined; + const renderSaveActions = ({ + hydratedComponent, + onChoose, + }: { + hydratedComponent: HydratedComponentReference; + onChoose: (action: "update" | "import" | "place") => void; + }) => { + const { inputDiff, outputDiff } = diffComponentIO< + { name: string; type?: unknown }, + { name: string; type?: unknown } + >(task.resolvedComponentSpec, hydratedComponent.spec); + + return ( + + ); + }; + const handleComponentSaved = ( hydratedComponent: HydratedComponentReference, + action: SaveAction, ) => { + if (action !== "update") { + // "place" arrives once placement ships; nothing else applies in place. + return; + } + const result = replaceTask(spec, task.$id, hydratedComponent); const lostInputs = result.inputDiff?.lostEntities ?? []; @@ -182,6 +213,7 @@ export const TaskDetails = observer(function TaskDetails({ taskName={task.name} pythonCode={pythonCode} onComponentSaved={handleComponentSaved} + renderSaveActions={renderSaveActions} /> diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ComponentRefBar.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ComponentRefBar.tsx index 3dffbab27..6978324d9 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ComponentRefBar.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/components/ComponentRefBar.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { CodeViewer } from "@/components/shared/CodeViewer"; import { ComponentEditorDialog } from "@/components/shared/ComponentEditor/ComponentEditorDialog"; +import type { SaveAction } from "@/components/shared/ComponentEditor/saveAction"; import ComponentDetailsDialog from "@/components/shared/Dialogs/ComponentDetailsDialog"; import { TrimmedDigest } from "@/components/shared/ManageComponent/TrimmedDigest"; import { Button } from "@/components/ui/button"; @@ -38,7 +39,12 @@ interface ComponentRefBarProps { pythonCode: string | undefined; onComponentSaved?: ( hydratedComponent: HydratedComponentReference, + action: SaveAction, ) => void | Promise; + renderSaveActions?: (args: { + hydratedComponent: HydratedComponentReference; + onChoose: (action: "update" | "import" | "place") => void; + }) => ReactNode; } export function ComponentRefBar({ @@ -47,6 +53,7 @@ export function ComponentRefBar({ taskName, pythonCode, onComponentSaved, + renderSaveActions, }: ComponentRefBarProps) { const { track } = useAnalytics(); const notify = useToastNotification(); @@ -201,6 +208,7 @@ export function ComponentRefBar({ text={yamlText} onClose={() => setIsEditDialogOpen(false)} onComponentSaved={onComponentSaved} + renderSaveActions={renderSaveActions} /> )}