From 011316fc6e4edc088e6832bc1829a726dad402fa Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Fri, 5 Jun 2026 13:18:02 -0700 Subject: [PATCH] Stage cross-pipeline reconcile on the canvas with a node-anchored commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the in-editor reconcile experience. When a pipeline opens in reconcile mode (?reconcile=): - autosave is held (new AutoSaveStore.suspended flag) so the change is staged in-memory only — rendered immediately, fully undoable, nothing persisted - the edited component is applied to every matching task (incl. nested in subgraphs, via findTaskContext) in one undo group, and the targets are spotlighted - a node-anchored 'Finish Reconciling' button (NodeToolbar, follows the task on move/pan/zoom) commits: resume autosave, save once, return to the origin pipeline's overview. Cancel/leave discards the staged change (reloads fresh). The overview is now URL-driven (?reconcileOverview=) via ReconcileOverviewHost so it opens on launch and reopens on return, enabling 'reconcile next'. The session stores the origin pipeline for a structured return. --- src/routes/v2/pages/Editor/EditorV2.tsx | 2 + .../components/FlowCanvas/FlowCanvas.tsx | 2 + .../lineage/ReconcileModeController.tsx | 156 ++++++++++++++++++ .../Editor/lineage/ReconcileOverview.tsx | 65 +++----- .../Editor/lineage/ReconcileOverviewHost.tsx | 43 +++++ .../Editor/lineage/reconcileSession.test.ts | 2 +- .../pages/Editor/lineage/reconcileSession.ts | 4 +- .../context/TaskDetails/TaskDetails.tsx | 44 +++-- .../v2/pages/Editor/store/autoSaveStore.ts | 14 +- 9 files changed, 266 insertions(+), 66 deletions(-) create mode 100644 src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx create mode 100644 src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 3ac41b731..4d1f9ce63 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -50,6 +50,7 @@ import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync"; import { useSpecLifecycle } from "./hooks/useSpecLifecycle"; import { useTipOfTheDayWindow } from "./hooks/useTipOfTheDayWindow"; import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard"; +import { ReconcileOverviewHost } from "./lineage/ReconcileOverviewHost"; import { useReconcileFromUrl } from "./lineage/useReconcileFromUrl"; import { editorRegistry } from "./nodes"; import { EditorSessionProvider } from "./store/EditorSessionContext"; @@ -152,6 +153,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { )} + ); diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx index 6b9eeef21..b7a08d26b 100644 --- a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"; import type { ComponentSpec } from "@/models/componentSpec"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useAutoLayout } from "@/routes/v2/pages/Editor/hooks/useAutoLayout"; +import { ReconcileModeController } from "@/routes/v2/pages/Editor/lineage/ReconcileModeController"; import { SubgraphBreadcrumbs } from "@/routes/v2/shared/components/SubgraphBreadcrumbs"; import { FLOW_CANVAS_DEFAULT_PROPS } from "@/routes/v2/shared/flowCanvasDefaults"; import { useDoubleClickBehavior } from "@/routes/v2/shared/hooks/useDoubleClickBehavior"; @@ -122,6 +123,7 @@ export const FlowCanvas = observer(function FlowCanvas({ )} > + ` so `NodeToolbar` can anchor to the target nodes. + * Nothing is persisted until Finish; Cancel / leaving discards the staged change + * (the pipeline reloads fresh from storage). + */ +export const ReconcileModeController = observer( + function ReconcileModeController() { + const session = reconcileModeStore.session; + const spec = useSpec(); + const navigate = useNavigate(); + const { autoSave, undo } = useEditorSession(); + const { replaceTask } = useTaskActions(); + const { editor } = useSharedStores(); + + const [ready, setReady] = useState(false); + const [matchTaskIds, setMatchTaskIds] = useState([]); + const stagedSessionRef = useRef(null); + + useEffect(() => { + if (!session) { + stagedSessionRef.current = null; + setReady(false); + setMatchTaskIds([]); + } + }, [session]); + + useEffect(() => { + if (!session || !spec) return; + if (stagedSessionRef.current === session.sessionId) return; + + let cancelled = false; + void (async () => { + const component = await hydrateComponentReference({ + text: session.targetComponentText, + }); + if (cancelled || !component) return; + + const matches = collectLineageUsages(spec, session.originId).filter( + (m) => m.digest !== session.targetDigest, + ); + + stagedSessionRef.current = session.sessionId; + + if (matches.length > 0) { + autoSave.setSuspended(true); + undo.withGroup("Reconcile component", () => { + for (const match of matches) { + const ctx = findTaskContext(spec, match.taskId); + if (ctx) replaceTask(ctx.spec, match.taskId, component); + } + }); + editor.setPendingFocusNode(matches[0].taskId); + editor.setSpotlightNode(matches[0].taskId); + } + + if (!cancelled) { + setMatchTaskIds(matches.map((m) => m.taskId)); + setReady(true); + } + })(); + + return () => { + cancelled = true; + }; + // Stage once per session; deps kept stable intentionally. + }, [session?.sessionId, spec]); + + if (!session || !ready) return null; + + const returnToOverview = () => + navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName: session.returnToPipeline }, + search: { reconcileOverview: session.sessionId }, + }); + + const finish = async () => { + autoSave.setSuspended(false); + await autoSave.save(); + reconcileModeStore.exit(); + await returnToOverview(); + }; + + const leave = async () => { + // Leave without saving — the staged change is discarded on reload. + reconcileModeStore.exit(); + await returnToOverview(); + }; + + const count = matchTaskIds.length; + + return ( + <> + {count > 0 && ( + + + + )} + +
+ + + {count > 0 ? ( + + Reconciling {session.targetName} · {count}{" "} + {count === 1 ? "task" : "tasks"} staged + + ) : ( + Nothing to reconcile in this pipeline + )} + + +
+ + ); + }, +); diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx index fc9732edc..ac323d8a9 100644 --- a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx +++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx @@ -1,5 +1,5 @@ -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -15,42 +15,34 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { APP_ROUTES } from "@/routes/router"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; -import type { HydratedComponentReference } from "@/utils/componentSpec"; -import { - createReconcileSession, - type ReconcileSession, - updateReconcileSession, -} from "./reconcileSession"; +import type { ReconcileSession } from "./reconcileSession"; import { type PipelineLineageMatch, scanPipelinesForLineage, } from "./scanPipelinesForLineage"; interface ReconcileOverviewProps { - /** The edited (target) component every instance is reconciled to. */ - component: HydratedComponentReference; - originId: string; + session: ReconcileSession; onClose: () => void; } /** - * Cross-pipeline reconcile overview: lists every locally-stored pipeline that - * uses the edited component's origin and lets the user reconcile each in turn. - * Clicking "Reconcile" flushes the current pipeline, then routes to the target - * pipeline in reconcile mode (`?reconcile=`), where the change is - * staged and committed via the node-anchored "Finish Reconciling" button. + * Cross-pipeline reconcile overview (URL-driven via `?reconcileOverview=`): + * lists every locally-stored pipeline using the edited component's origin and + * lets the user reconcile each in turn. Status is recomputed by re-scan on each + * open, so it is self-healing across refresh / back / "reconcile next". Clicking + * "Reconcile" flushes the current pipeline, then routes to the target in + * reconcile mode (`?reconcile=`), where the change is staged and committed + * via the node-anchored "Finish Reconciling" button. */ export function ReconcileOverview({ - component, - originId, + session, onClose, }: ReconcileOverviewProps) { const navigate = useNavigate(); - const location = useLocation(); const { autoSave } = useEditorSession(); - const sessionRef = useRef(null); const [pipelines, setPipelines] = useState( null, ); @@ -58,37 +50,18 @@ export function ReconcileOverview({ useEffect(() => { let cancelled = false; void (async () => { - const results = await scanPipelinesForLineage(originId, component.digest); - if (cancelled) return; - - const session = createReconcileSession({ - originId, - targetDigest: component.digest, - targetComponentText: component.text, - targetName: component.name, - returnTo: "", - worklist: results.map((r) => ({ - storageKey: r.storageKey, - status: "pending" as const, - })), - }); - const returnTo = `${location.pathname}?reconcileOverview=${session.sessionId}`; - updateReconcileSession(session.sessionId, { returnTo }); - sessionRef.current = { ...session, returnTo }; - - setPipelines(results); + const results = await scanPipelinesForLineage( + session.originId, + session.targetDigest, + ); + if (!cancelled) setPipelines(results); })(); return () => { cancelled = true; }; - // Intentionally runs once when the overview opens; props are fixed for its - // lifetime (it is mounted only while active). - }, []); + }, [session.originId, session.targetDigest]); const handleReconcile = async (storageKey: string) => { - const session = sessionRef.current; - if (!session) return; - // Persist the current pipeline before navigating away. await autoSave.save(); await navigate({ to: APP_ROUTES.EDITOR_V2_PIPELINE, @@ -109,7 +82,7 @@ export function ReconcileOverview({ - Reconcile “{component.name}” across pipelines + Reconcile “{session.targetName}” across pipelines Update other pipelines that use this component’s origin to your diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx new file mode 100644 index 000000000..466988366 --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx @@ -0,0 +1,43 @@ +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; + +import { APP_ROUTES } from "@/routes/router"; + +import { ReconcileOverview } from "./ReconcileOverview"; +import { getReconcileSession } from "./reconcileSession"; + +/** + * Renders the cross-pipeline reconcile overview when the URL carries + * `?reconcileOverview=` and the session still exists. Being URL-driven + * means the overview opens on launch and reopens automatically when a target + * pipeline routes back here after committing ("reconcile next"). + */ +export function ReconcileOverviewHost() { + const search = useSearch({ strict: false }); + const params = useParams({ strict: false }); + const navigate = useNavigate(); + + const overviewId = + "reconcileOverview" in search && + typeof search.reconcileOverview === "string" + ? search.reconcileOverview + : undefined; + + const session = overviewId ? getReconcileSession(overviewId) : undefined; + if (!overviewId || !session) return null; + + const close = () => { + const pipelineName = + "pipelineName" in params && typeof params.pipelineName === "string" + ? params.pipelineName + : undefined; + if (pipelineName) { + void navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName }, + search: {}, + }); + } + }; + + return ; +} diff --git a/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts b/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts index 8b9dc322d..f6cf1e527 100644 --- a/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts +++ b/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts @@ -28,7 +28,7 @@ const baseInput: Omit = { targetDigest: "edited-digest", targetComponentText: "name: Train\n", targetName: "Train", - returnTo: "/editor-v2/Origin?reconcileOverview=X", + returnToPipeline: "Origin", worklist: [{ storageKey: "Pipeline A", status: "pending" }], }; diff --git a/src/routes/v2/pages/Editor/lineage/reconcileSession.ts b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts index 6595bc05b..3fdb9fec2 100644 --- a/src/routes/v2/pages/Editor/lineage/reconcileSession.ts +++ b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts @@ -27,8 +27,8 @@ const reconcileSessionSchema = z.object({ /** Self-contained edited component YAML, so the session needs no store lookup. */ targetComponentText: z.string(), targetName: z.string(), - /** Where to return when reconciling ends (origin editor + reconcile overview). */ - returnTo: z.string(), + /** Origin pipeline (storage key) to return to — reopens the overview there. */ + returnToPipeline: z.string(), worklist: z.array(worklistItemSchema), createdAt: z.number(), }); 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 62d8c991b..eecbc772b 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,4 +1,4 @@ -import { useParams } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { observer } from "mobx-react-lite"; import { useEffect, useRef, useState } from "react"; @@ -16,6 +16,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Heading, Text } from "@/components/ui/typography"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { APP_ROUTES } from "@/routes/router"; 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"; @@ -24,7 +25,7 @@ import { type LineageUsage, } from "@/routes/v2/pages/Editor/lineage/collectLineageUsages"; import { findTaskContext } from "@/routes/v2/pages/Editor/lineage/findTaskContext"; -import { ReconcileOverview } from "@/routes/v2/pages/Editor/lineage/ReconcileOverview"; +import { createReconcileSession } from "@/routes/v2/pages/Editor/lineage/reconcileSession"; import { ReconcileSiblingsDialog } from "@/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog"; import { scanPipelinesForLineage } from "@/routes/v2/pages/Editor/lineage/scanPipelinesForLineage"; import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions"; @@ -63,6 +64,7 @@ export const TaskDetails = observer(function TaskDetails({ const { undo } = useEditorSession(); const { renameTask, replaceTask, addTask } = useTaskActions(); const notify = useToastNotification(); + const navigate = useNavigate(); const spec = useSpec(); const task = useTask(entityId); const { focusedArgumentName } = editor; @@ -80,12 +82,30 @@ export const TaskDetails = observer(function TaskDetails({ matches: LineageUsage[]; crossPipelineCount: number; } | null>(null); - const [overview, setOverview] = useState<{ - component: HydratedComponentReference; - originId: string; - } | null>(null); const renameInputRef = useRef(null); + // Start a cross-pipeline reconcile: create a session carrying the edited + // component and open the overview via the URL (so it reopens on return). + const launchReconcileOverview = ( + component: HydratedComponentReference, + originId: string, + ) => { + if (!currentPipelineKey) return; + const session = createReconcileSession({ + originId, + targetDigest: component.digest, + targetComponentText: component.text, + targetName: component.name, + returnToPipeline: currentPipelineKey, + worklist: [], + }); + void navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName: currentPipelineKey }, + search: { reconcileOverview: session.sessionId }, + }); + }; + useEffect(() => { if (focusedArgumentName) { setDetailsTab("arguments"); @@ -189,7 +209,7 @@ export const TaskDetails = observer(function TaskDetails({ crossPipelineCount: otherPipelinesPending, }); } else if (otherPipelinesPending > 0) { - setOverview({ component: hydratedComponent, originId }); + launchReconcileOverview(hydratedComponent, originId); } }, ); @@ -455,20 +475,12 @@ export const TaskDetails = observer(function TaskDetails({ LINEAGE_ORIGIN_ANNOTATION, )?.originId; setReconcile(null); - if (originId) setOverview({ component, originId }); + if (originId) launchReconcileOverview(component, originId); }} onConfirm={handleReconcileConfirm} onCancel={() => setReconcile(null)} /> )} - - {overview && ( - setOverview(null)} - /> - )} ); }); diff --git a/src/routes/v2/pages/Editor/store/autoSaveStore.ts b/src/routes/v2/pages/Editor/store/autoSaveStore.ts index 871a43a34..881b60b4e 100644 --- a/src/routes/v2/pages/Editor/store/autoSaveStore.ts +++ b/src/routes/v2/pages/Editor/store/autoSaveStore.ts @@ -17,6 +17,12 @@ const AUTOSAVE_MIN_SAVING_INDICATOR_MS = 600; export class AutoSaveStore { @observable accessor isSaving = false; @observable accessor lastSavedAt: Date | null = null; + /** + * When true, debounced autosave is held — used during reconcile so the staged + * change stays in-memory until the explicit "Finish Reconciling" commit. + * Explicit `save()` still works. + */ + @observable accessor suspended = false; private spec: ComponentSpec | null = null; private pipelineName: string | null = null; @@ -39,6 +45,7 @@ export class AutoSaveStore { this.pipelineName = pipelineName; this.isSaving = false; this.lastSavedAt = null; + this.suspended = false; this.disposeReaction = reaction( () => this.serializeSpec(), @@ -71,6 +78,11 @@ export class AutoSaveStore { this.isSaving = false; } + @action setSuspended(value: boolean) { + this.suspended = value; + if (value) this.debouncedSave.cancel(); + } + private serializeSpec(): string | null { if (!this.spec) return null; try { @@ -81,7 +93,7 @@ export class AutoSaveStore { } private scheduleAutoSave(yamlText: string | null) { - if (!yamlText || !this.pipelineName) { + if (this.suspended || !yamlText || !this.pipelineName) { this.debouncedSave.cancel(); return; }