diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx
index c9a3f2a59..486418123 100644
--- a/src/routes/v2/pages/Editor/EditorV2.tsx
+++ b/src/routes/v2/pages/Editor/EditorV2.tsx
@@ -50,6 +50,8 @@ import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync";
import { useSpecLifecycle } from "./hooks/useSpecLifecycle";
import { useTipOfTheDayWindow } from "./hooks/useTipOfTheDayWindow";
import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard";
+import { CopyLineageModal } from "./lineage/CopyLineageModal";
+import { PasteLineagePrompt } from "./lineage/PasteLineagePrompt";
import { reconcileModeStore } from "./lineage/reconcileModeStore";
import { ReconcileNavigationGuard } from "./lineage/ReconcileNavigationGuard";
import { ReconcileOverviewHost } from "./lineage/ReconcileOverviewHost";
@@ -123,6 +125,8 @@ const PipelineEditor = withSuspenseWrapper(
className="h-full"
/>
+
+
diff --git a/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx b/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx
new file mode 100644
index 000000000..cbbbc47cf
--- /dev/null
+++ b/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx
@@ -0,0 +1,109 @@
+import { observer } from "mobx-react-lite";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { BlockStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
+import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
+import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage";
+
+/**
+ * Modal shown when the user initiates a copy (Cmd+C / Copy button) and the
+ * copied tasks have no lineage annotation. The clipboard write is deferred
+ * until the user clicks "Copy". Checking the box stamps the source tasks with
+ * a shared origin first, so any future paste — including cross-pipeline —
+ * inherits the lineage and can participate in reconcile detection.
+ *
+ * Dismissing via ✕ or Escape completes the copy without tracking (same as
+ * clicking "Copy" with the checkbox unchecked).
+ */
+export const CopyLineageModal = observer(function CopyLineageModal() {
+ const spec = useSpec();
+ const { clipboard } = useEditorSession();
+ const ctx = clipboard.pendingCopyContext;
+
+ const [track, setTrack] = useState(false);
+
+ if (!ctx || !spec) return null;
+
+ // Collect task names for display.
+ const sourceNames = ctx.nodeIds
+ .map((id) => spec.tasks.find((t) => t.$id === id)?.name)
+ .filter(Boolean) as string[];
+
+ const label =
+ sourceNames.length === 1
+ ? `"${sourceNames[0]}"`
+ : `${sourceNames.length} tasks`;
+
+ // Check which tasks actually still lack lineage (defensive — could have been
+ // stamped by another operation since the copy was initiated).
+ const hasAnyUnlinked = ctx.nodeIds.some((id) => {
+ const task = spec.tasks.find((t) => t.$id === id);
+ return task && !task.annotations.has(LINEAGE_ORIGIN_ANNOTATION);
+ });
+
+ if (!hasAnyUnlinked) {
+ // All tasks are already linked — just commit the copy silently.
+ clipboard.executeCopy(false, spec);
+ return null;
+ }
+
+ const handleCopy = (shouldTrack: boolean) => {
+ setTrack(false);
+ clipboard.executeCopy(shouldTrack, spec);
+ };
+
+ return (
+
+ );
+});
diff --git a/src/routes/v2/pages/Editor/lineage/PasteLineagePrompt.tsx b/src/routes/v2/pages/Editor/lineage/PasteLineagePrompt.tsx
new file mode 100644
index 000000000..5912bc074
--- /dev/null
+++ b/src/routes/v2/pages/Editor/lineage/PasteLineagePrompt.tsx
@@ -0,0 +1,116 @@
+import { observer } from "mobx-react-lite";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { BlockStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
+import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
+import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage";
+
+/**
+ * Modal shown after pasting or duplicating a task that had no lineage, when the
+ * source task is still in the current pipeline. The pasted copy has already been
+ * auto-stamped with a fresh origin id. The user can optionally check "Track
+ * changes to the original" to back-link the source task to the same origin so
+ * edits to either will offer to update the other.
+ *
+ * Shown only for same-pipeline operations — cross-pipeline source tasks won't
+ * be found in the current spec, so no modal fires for those.
+ */
+export const PasteLineagePrompt = observer(function PasteLineagePrompt() {
+ const spec = useSpec();
+ const { clipboard, undo } = useEditorSession();
+ const ctx = clipboard.latestPasteContext;
+
+ const [track, setTrack] = useState(false);
+
+ if (!ctx || !spec) return null;
+
+ // Find pairs where the source exists in the current spec but has no lineage,
+ // and the new task was freshly stamped. These are the linkable candidates.
+ const candidates = [...ctx.idMap.entries()].flatMap(
+ ([sourceEntityId, newTaskId]) => {
+ const sourceTask = spec.tasks.find((t) => t.$id === sourceEntityId);
+ const newTask = spec.tasks.find((t) => t.$id === newTaskId);
+ if (!sourceTask || !newTask) return [];
+ if (sourceTask.annotations.has(LINEAGE_ORIGIN_ANNOTATION)) return [];
+ const lineage = newTask.annotations.get(LINEAGE_ORIGIN_ANNOTATION);
+ if (!lineage) return [];
+ return [{ sourceTask, newTaskId, originId: lineage.originId }];
+ },
+ );
+
+ if (candidates.length === 0) return null;
+
+ const sourceNames =
+ candidates.length === 1
+ ? `"${candidates[0].sourceTask.name}"`
+ : `${candidates.length} tasks`;
+
+ const handleDone = () => {
+ if (track) {
+ undo.withGroup("Link task lineage", () => {
+ for (const { sourceTask, originId } of candidates) {
+ sourceTask.annotations.set(LINEAGE_ORIGIN_ANNOTATION, {
+ originId,
+ originDigest: sourceTask.componentRef.digest,
+ originName: sourceTask.componentRef.name,
+ });
+ }
+ });
+ }
+ clipboard.clearPasteContext();
+ };
+
+ return (
+
+ );
+});
diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx
index 2530ebec6..2f0dc86d1 100644
--- a/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx
+++ b/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx
@@ -11,9 +11,29 @@ import { APP_ROUTES } from "@/routes/router";
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";
+import type { NavigationStore } from "@/routes/v2/shared/store/navigationStore";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { hydrateComponentReference } from "@/services/componentService";
+/**
+ * Walk a subgraph path (array of task names), calling navigateToSubgraph at
+ * each level. MobX updates navigation.activeSpec synchronously after each call,
+ * so reading it in the next iteration gives the correct deeper spec.
+ */
+function navigateToSubgraphPath(
+ navigation: NavigationStore,
+ path: string[],
+): void {
+ for (const taskName of path) {
+ const currentSpec = navigation.activeSpec;
+ if (!currentSpec) break;
+ const task = currentSpec.tasks.find((t) => t.name === taskName);
+ if (task?.subgraphSpec) {
+ navigation.navigateToSubgraph(currentSpec, task.$id);
+ }
+ }
+}
+
import { collectLineageUsages } from "./collectLineageUsages";
import { findTaskContext } from "./findTaskContext";
import { reconcileModeStore } from "./reconcileModeStore";
@@ -35,7 +55,7 @@ export const ReconcileModeController = observer(
const navigate = useNavigate();
const { autoSave, undo } = useEditorSession();
const { replaceTask } = useTaskActions();
- const { editor } = useSharedStores();
+ const { editor, navigation } = useSharedStores();
const [ready, setReady] = useState(false);
const [matchTaskIds, setMatchTaskIds] = useState([]);
@@ -60,9 +80,18 @@ export const ReconcileModeController = observer(
});
if (cancelled || !component) return;
- const matches = collectLineageUsages(spec, session.originId).filter(
- (m) => m.digest !== session.targetDigest,
- );
+ // Navigate into the target subgraph depth before staging. MobX updates
+ // navigation.activeSpec synchronously, so we read it immediately after.
+ navigateToSubgraphPath(navigation, session.targetSubgraphPath ?? []);
+
+ // Use navigation.activeSpec (reflects any subgraph navigation above)
+ // rather than the spec prop, which still reflects the pre-nav render.
+ const targetSpec = navigation.activeSpec ?? spec;
+
+ const matches = collectLineageUsages(
+ targetSpec,
+ session.originId,
+ ).filter((m) => m.digest !== session.targetDigest);
stagedSessionRef.current = session.sessionId;
@@ -70,7 +99,7 @@ export const ReconcileModeController = observer(
autoSave.setSuspended(true);
undo.withGroup("Reconcile component", () => {
for (const match of matches) {
- const ctx = findTaskContext(spec, match.taskId);
+ const ctx = findTaskContext(targetSpec, match.taskId);
if (ctx) replaceTask(ctx.spec, match.taskId, component);
}
});
diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx
index ac323d8a9..c7c200451 100644
--- a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx
+++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx
@@ -17,35 +17,50 @@ import { APP_ROUTES } from "@/routes/router";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import type { ReconcileSession } from "./reconcileSession";
+import { updateReconcileSession } from "./reconcileSession";
import {
- type PipelineLineageMatch,
+ type ReconcileTarget,
scanPipelinesForLineage,
} from "./scanPipelinesForLineage";
interface ReconcileOverviewProps {
session: ReconcileSession;
- onClose: () => void;
+ onFinish: () => void;
}
/**
- * 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.
+ * Format an author string for compact display.
+ * morgan.wowk@shopify.com → Morgan
+ * mwowk@shopify.com → Mwowk
+ * Tangle Dev Team → Tangle Dev Team (unchanged)
*/
+function formatAuthor(raw: string | undefined): string | undefined {
+ if (!raw) return undefined;
+ if (raw.includes("@")) {
+ const left = raw.split("@")[0];
+ const name = left.includes(".") ? left.split(".")[0] : left;
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
+ }
+ return raw;
+}
+
+/**
+ * Human-readable label for a reconcile target row.
+ * Root level shows just the pipeline name; subgraph levels append the path.
+ */
+function targetLabel(target: ReconcileTarget): string {
+ if (target.subgraphPath.length === 0) return target.pipelineName;
+ return `${target.pipelineName} › ${target.subgraphPath.join(" › ")}`;
+}
+
export function ReconcileOverview({
session,
- onClose,
+ onFinish,
}: ReconcileOverviewProps) {
const navigate = useNavigate();
const { autoSave } = useEditorSession();
- const [pipelines, setPipelines] = useState(
- null,
- );
+ const [targets, setTargets] = useState(null);
useEffect(() => {
let cancelled = false;
@@ -54,100 +69,134 @@ export function ReconcileOverview({
session.originId,
session.targetDigest,
);
- if (!cancelled) setPipelines(results);
+ if (!cancelled) setTargets(results);
})();
return () => {
cancelled = true;
};
}, [session.originId, session.targetDigest]);
- const handleReconcile = async (storageKey: string) => {
+ const handleReconcile = async (target: ReconcileTarget) => {
+ // Store the target subgraph path so ReconcileModeController can navigate
+ // into the right depth automatically after the pipeline loads.
+ updateReconcileSession(session.sessionId, {
+ targetSubgraphPath: target.subgraphPath,
+ });
await autoSave.save();
await navigate({
to: APP_ROUTES.EDITOR_V2_PIPELINE,
- params: { pipelineName: storageKey },
+ params: { pipelineName: target.storageKey },
search: { reconcile: session.sessionId },
});
};
- const totalPending = pipelines?.reduce((n, p) => n + p.pendingCount, 0) ?? 0;
+ const copyLink = (storageKey: string) => {
+ const url = `${window.location.origin}/editor-v2/${encodeURIComponent(storageKey)}`;
+ void navigator.clipboard.writeText(url);
+ };
return (