(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;
}