From 09da6491fdfff31bf345df5edb978a2362de2615 Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Fri, 5 Jun 2026 14:53:52 -0700 Subject: [PATCH] Auto-stamp lineage on paste + offer to track original MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a task with no lineage annotation is pasted or duplicated: - The clone gets a fresh origin stamped automatically: url ?? digest ?? UUID. From this point forward every subsequent copy of the pasted task carries a stable lineage, enabling future reconcile detection. - PasteLineagePrompt surfaces a non-blocking toast above the canvas with a checkbox (unchecked by default) and a single Done button. If the user checks 'Track changes to the original' before dismissing, the source task is back-linked to the same origin in a single undo step — so edits to either task will offer to update the other. - Prompt auto-dismisses after 6 s if untouched. Only shown for same-pipeline operations: cross-pipeline source tasks are absent from the current spec so no prompt fires. Cross-pipeline paste still gets the auto-stamp on the clone. - ClipboardStore.latestPasteContext carries the source→new idMap so the prompt can match pasted tasks back to their origins without any side-channel. cloneSnapshotsWithBindings now returns { newIds, idMap } (CloneResult). --- src/routes/v2/pages/Editor/EditorV2.tsx | 4 + .../pages/Editor/lineage/CopyLineageModal.tsx | 109 +++++++++++ .../Editor/lineage/PasteLineagePrompt.tsx | 116 ++++++++++++ .../lineage/ReconcileModeController.tsx | 39 +++- .../Editor/lineage/ReconcileOverview.tsx | 175 +++++++++++------- .../Editor/lineage/ReconcileOverviewHost.tsx | 11 +- .../Editor/lineage/pasteLineageStamp.test.ts | 64 +++++++ .../pages/Editor/lineage/reconcileSession.ts | 11 ++ .../lineage/scanPipelinesForLineage.test.ts | 22 ++- .../Editor/lineage/scanPipelinesForLineage.ts | 82 +++++--- .../Editor/lineage/useReconcileFromUrl.ts | 8 +- .../context/TaskDetails/TaskDetails.tsx | 7 +- .../nodes/TaskNode/taskNode.manifest.ts | 25 +++ .../Editor/store/clipboardStore.helpers.ts | 10 +- .../v2/pages/Editor/store/clipboardStore.ts | 114 +++++++++++- .../v2/shared/hooks/useReconcileUIMode.ts | 22 +++ src/routes/v2/shared/windows/DockArea.tsx | 14 +- 17 files changed, 713 insertions(+), 120 deletions(-) create mode 100644 src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx create mode 100644 src/routes/v2/pages/Editor/lineage/PasteLineagePrompt.tsx create mode 100644 src/routes/v2/pages/Editor/lineage/pasteLineageStamp.test.ts create mode 100644 src/routes/v2/shared/hooks/useReconcileUIMode.ts 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 ( + { + // X / Escape / backdrop click → copy without tracking. + if (!open) handleCopy(false); + }} + > + + + Copy {label} + + Would you like to track changes between {label} and any copies you + paste? Edits to either will offer to update the other. + + + + + + + + + + + + + ); +}); 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 ( + { + if (!open) clipboard.clearPasteContext(); + }} + > + + + Track changes to the original? + + You pasted a copy of {sourceNames}. If you track it, edits to either + the original or the copy will offer to update the other. + + + + + + + + + + + + + ); +}); 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 ( { - if (!isOpen) onClose(); + if (!isOpen) onFinish(); }} > - Reconcile “{session.targetName}” across pipelines + Reconcile “{session.targetName}” across pipelines - Update other pipelines that use this component’s origin to your - edited version. Each opens in the editor so you can review and apply - the change in context. + Update other pipelines (and subgraphs) that use this + component's origin to your edited version. Each opens directly + at the right context. - - {pipelines === null && ( - + + {targets === null && ( + Scanning pipelines… )} - {pipelines !== null && pipelines.length === 0 && ( - - No other pipelines use this component. + {targets !== null && targets.length === 0 && ( + + No pipelines use this component. )} - {pipelines?.map((pipeline) => { - const done = pipeline.pendingCount === 0; + {targets?.map((target) => { + const done = target.pendingCount === 0; + const author = formatAuthor(target.author); + const label = targetLabel(target); + return ( - - - - {pipeline.pipelineName} - - - {done - ? `${pipeline.reconciledCount} reconciled` - : `${pipeline.pendingCount} of ${pipeline.tasks.length} to update`} - - + {/* Status dot */} + - {done ? ( - - - - Reconciled - - - ) : ( - - )} - + {label} + + {author && ( + + {author} + + )} + + + {/* Right-side actions */} + + {done ? ( + <> + + + + ) : ( + + )} + + ); })} - diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx index 466988366..8beeb31aa 100644 --- a/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx +++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx @@ -1,6 +1,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { APP_ROUTES } from "@/routes/router"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { ReconcileOverview } from "./ReconcileOverview"; import { getReconcileSession } from "./reconcileSession"; @@ -15,6 +16,7 @@ export function ReconcileOverviewHost() { const search = useSearch({ strict: false }); const params = useParams({ strict: false }); const navigate = useNavigate(); + const { editor } = useSharedStores(); const overviewId = "reconcileOverview" in search && @@ -25,7 +27,7 @@ export function ReconcileOverviewHost() { const session = overviewId ? getReconcileSession(overviewId) : undefined; if (!overviewId || !session) return null; - const close = () => { + const finish = () => { const pipelineName = "pipelineName" in params && typeof params.pipelineName === "string" ? params.pipelineName @@ -37,7 +39,12 @@ export function ReconcileOverviewHost() { search: {}, }); } + // Spotlight the task that was originally edited so the user lands with context. + if (session.originTaskId) { + editor.setPendingFocusNode(session.originTaskId); + editor.setSpotlightNode(session.originTaskId); + } }; - return ; + return ; } diff --git a/src/routes/v2/pages/Editor/lineage/pasteLineageStamp.test.ts b/src/routes/v2/pages/Editor/lineage/pasteLineageStamp.test.ts new file mode 100644 index 000000000..b573256ea --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/pasteLineageStamp.test.ts @@ -0,0 +1,64 @@ +/** + * Tests the auto-stamp behavior when cloning a task with no lineage: + * the pasted copy should receive a fresh origin derived from the source's + * url/digest, or a UUID if neither is present. + */ +import { describe, expect, it } from "vitest"; + +import { + type ComponentLineage, + LINEAGE_ORIGIN_ANNOTATION, +} from "@/utils/lineage"; + +type SourceRef = { url?: string; digest?: string; name?: string }; + +/** + * Inline copy of the logic from taskNode.manifest.ts clone handler. + * Kept here as a pure function so we can test without pulling in the + * editor-registry barrel (which transitively loads appSettings). + */ +function freshLineageFor(ref: SourceRef): ComponentLineage { + return { + originId: ref.url ?? ref.digest ?? "generated-uuid", + originDigest: ref.digest, + originName: ref.name, + }; +} + +describe("paste lineage auto-stamp logic", () => { + it("uses the component url as originId when available", () => { + const lineage = freshLineageFor({ + url: "https://x/train.yaml", + digest: "abc", + name: "Train", + }); + expect(lineage.originId).toBe("https://x/train.yaml"); + expect(lineage.originDigest).toBe("abc"); + expect(lineage.originName).toBe("Train"); + }); + + it("falls back to digest when there is no url", () => { + const lineage = freshLineageFor({ digest: "abc", name: "Train" }); + expect(lineage.originId).toBe("abc"); + }); + + it("falls back to a placeholder when neither url nor digest is present", () => { + const lineage = freshLineageFor({ name: "Custom" }); + expect(lineage.originId).toBeTruthy(); + expect(lineage.originId).toBe("generated-uuid"); + }); + + it("a task snapshot with existing lineage annotation should not be replaced", () => { + const existing: ComponentLineage = { + originId: "original", + originDigest: "orig-digest", + }; + const sourceAnnotations = [ + { key: LINEAGE_ORIGIN_ANNOTATION, value: existing }, + ]; + const hasLineage = sourceAnnotations.some( + (a) => a.key === LINEAGE_ORIGIN_ANNOTATION, + ); + expect(hasLineage).toBe(true); // clone should preserve, not overwrite + }); +}); diff --git a/src/routes/v2/pages/Editor/lineage/reconcileSession.ts b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts index 3fdb9fec2..349efc99e 100644 --- a/src/routes/v2/pages/Editor/lineage/reconcileSession.ts +++ b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts @@ -29,6 +29,17 @@ const reconcileSessionSchema = z.object({ targetName: z.string(), /** Origin pipeline (storage key) to return to — reopens the overview there. */ returnToPipeline: z.string(), + /** + * $id of the task that was originally edited. Used to spotlight that task + * when the reconcile overview is closed (so the user lands with context). + */ + originTaskId: z.string().optional(), + /** + * Subgraph path (array of task names) for the target being reconciled. + * Empty or absent = root level. Set per-target when the user clicks Reconcile + * so ReconcileModeController can navigate into the right depth automatically. + */ + targetSubgraphPath: z.array(z.string()).optional(), worklist: z.array(worklistItemSchema), createdAt: z.number(), }); diff --git a/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.test.ts b/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.test.ts index acdf3c733..a2513d4cc 100644 --- a/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.test.ts +++ b/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.test.ts @@ -39,13 +39,14 @@ const pipeline = (name: string, tasks: Record) => ({ componentRef: { spec: { name, implementation: { graph: { tasks } } } }, }); + const asStore = (entries: Record) => new Map(Object.entries(entries)); describe("scanPipelinesForLineage", () => { beforeEach(() => mockGetAll.mockReset()); - it("returns pipelines with matching tasks and pending/reconciled counts", async () => { + it("returns one target per (pipeline, depth) with pending/reconciled counts", async () => { mockGetAll.mockResolvedValue( asStore({ "Pipeline A": pipeline("Pipeline A", { @@ -61,10 +62,12 @@ describe("scanPipelinesForLineage", () => { const results = await scanPipelinesForLineage(ORIGIN, TARGET); + // One target: Pipeline A root level (Pipeline B has no matching tasks) expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ storageKey: "Pipeline A", pipelineName: "Pipeline A", + subgraphPath: [], pendingCount: 1, reconciledCount: 1, }); @@ -74,10 +77,11 @@ describe("scanPipelinesForLineage", () => { ]); }); - it("recurses into subgraphs and records the path", async () => { + it("creates separate targets for root and subgraph depths", async () => { mockGetAll.mockResolvedValue( asStore({ "Pipeline C": pipeline("Pipeline C", { + "Root train": containerTask("Root train", "old", ORIGIN), Group: subgraphTask("Group", { "Nested train": containerTask("Nested train", "old", ORIGIN), }), @@ -87,12 +91,14 @@ describe("scanPipelinesForLineage", () => { const results = await scanPipelinesForLineage(ORIGIN, TARGET); - expect(results).toHaveLength(1); - expect(results[0].tasks[0]).toMatchObject({ - taskName: "Nested train", - subgraphPath: ["Group"], - reconciled: false, - }); + // Two separate targets: root level and inside Group + expect(results).toHaveLength(2); + const root = results.find((r) => r.subgraphPath.length === 0)!; + const nested = results.find((r) => r.subgraphPath.length > 0)!; + + expect(root.tasks[0].taskName).toBe("Root train"); + expect(nested.subgraphPath).toEqual(["Group"]); + expect(nested.tasks[0].taskName).toBe("Nested train"); }); it("returns nothing when no pipeline shares the origin", async () => { diff --git a/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.ts b/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.ts index bf10c971d..cdff23931 100644 --- a/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.ts +++ b/src/routes/v2/pages/Editor/lineage/scanPipelinesForLineage.ts @@ -12,21 +12,29 @@ import { parseLineage } from "@/utils/lineage"; interface PipelineLineageTaskMatch { taskName: string; - subgraphPath: string[]; digest?: string; - /** True when this task is already on the target (edited) version. */ reconciled: boolean; } -export interface PipelineLineageMatch { +/** + * A single reconcile target: a specific pipeline at a specific subgraph depth. + * Each unique (storageKey, subgraphPath) pair is its own row in the overview so + * the user can navigate directly to the right context. + */ +export interface ReconcileTarget { /** Storage key (also the pipeline route param). */ storageKey: string; pipelineName: string; + /** + * Path of task names leading to this subgraph level. + * Empty array = root level of the pipeline. + */ + subgraphPath: string[]; tasks: PipelineLineageTaskMatch[]; - /** Tasks sharing the origin but not yet on the target version. */ pendingCount: number; - /** Tasks already on the target version. */ reconciledCount: number; + modifiedAt: Date | undefined; + author: string | undefined; } function walkSpec( @@ -34,18 +42,19 @@ function walkSpec( originId: string, targetDigest: string | undefined, path: string[], - out: PipelineLineageTaskMatch[], + out: Map, ): void { const impl = spec?.implementation; if (!impl || !isGraphImplementation(impl)) return; + const pathKey = path.join("\0"); for (const [taskName, task] of Object.entries(impl.graph.tasks)) { const lineage = parseLineage(task.annotations?.[LINEAGE_ORIGIN_ANNOTATION]); if (lineage?.originId === originId) { + if (!out.has(pathKey)) out.set(pathKey, { path, tasks: [] }); const digest = task.componentRef.digest; - out.push({ + out.get(pathKey)!.tasks.push({ taskName, - subgraphPath: path, digest, reconciled: targetDigest != null && digest === targetDigest, }); @@ -60,34 +69,55 @@ function walkSpec( /** * Scan every locally-stored pipeline for tasks sharing `originId` (recursing - * through subgraphs). Pipelines live client-side, so this is the cross-pipeline - * discovery mechanism — no backend involved. `targetDigest` is the edited - * version: tasks already at it are counted as reconciled, the rest as pending. - * - * Returns only pipelines with at least one matching task. + * through subgraphs). Each unique (pipeline, subgraph depth) combination is + * returned as a separate ReconcileTarget so the overview can navigate directly + * to the right context. Results are sorted most-recently-updated first. */ export async function scanPipelinesForLineage( originId: string, targetDigest?: string, -): Promise { +): Promise { const files = await getAllComponentFilesFromList(USER_PIPELINES_LIST_NAME); - const results: PipelineLineageMatch[] = []; + const results: ReconcileTarget[] = []; + for (const [storageKey, entry] of files) { - const spec = (entry as ComponentFileEntry).componentRef.spec; - const tasks: PipelineLineageTaskMatch[] = []; - walkSpec(spec, originId, targetDigest, [], tasks); + const fileEntry = entry as ComponentFileEntry; + const spec = fileEntry.componentRef.spec; - if (tasks.length === 0) continue; + const grouped = new Map< + string, + { path: string[]; tasks: PipelineLineageTaskMatch[] } + >(); + walkSpec(spec, originId, targetDigest, [], grouped); - results.push({ - storageKey, - pipelineName: spec?.name ?? storageKey, - tasks, - pendingCount: tasks.filter((t) => !t.reconciled).length, - reconciledCount: tasks.filter((t) => t.reconciled).length, - }); + if (grouped.size === 0) continue; + + const pipelineName = spec?.name ?? storageKey; + const modifiedAt = fileEntry.modificationTime; + const author = spec?.metadata?.annotations?.author as string | undefined; + + for (const { path, tasks } of grouped.values()) { + results.push({ + storageKey, + pipelineName, + subgraphPath: path, + tasks, + pendingCount: tasks.filter((t) => !t.reconciled).length, + reconciledCount: tasks.filter((t) => t.reconciled).length, + modifiedAt, + author, + }); + } } + // Most recently updated first; no modification time goes last. + results.sort((a, b) => { + if (!a.modifiedAt && !b.modifiedAt) return 0; + if (!a.modifiedAt) return 1; + if (!b.modifiedAt) return -1; + return b.modifiedAt.getTime() - a.modifiedAt.getTime(); + }); + return results; } diff --git a/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts b/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts index 9d05d8f1c..95b11049f 100644 --- a/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts +++ b/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts @@ -1,7 +1,7 @@ import { useSearch } from "@tanstack/react-router"; import { useEffect } from "react"; -import { focusModeStore } from "@/routes/v2/shared/hooks/useFocusMode"; +import { reconcileUIModeStore } from "@/routes/v2/shared/hooks/useReconcileUIMode"; import { reconcileModeStore } from "./reconcileModeStore"; import { @@ -30,15 +30,13 @@ export function useReconcileFromUrl(): void { const session = sessionId ? getReconcileSession(sessionId) : undefined; if (session) { reconcileModeStore.enter(session); - // Reuse focus mode to hide dock panels while reconciling (shared store — - // keeps the architecture's pages → shared dependency direction). - focusModeStore.setActive(true); + reconcileUIModeStore.setActive(true); } else { reconcileModeStore.exit(); } return () => { reconcileModeStore.exit(); - focusModeStore.setActive(false); + reconcileUIModeStore.setActive(false); }; }, [sessionId]); } 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 eecbc772b..891086055 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 @@ -89,6 +89,7 @@ export const TaskDetails = observer(function TaskDetails({ const launchReconcileOverview = ( component: HydratedComponentReference, originId: string, + originTaskId?: string, ) => { if (!currentPipelineKey) return; const session = createReconcileSession({ @@ -97,6 +98,7 @@ export const TaskDetails = observer(function TaskDetails({ targetComponentText: component.text, targetName: component.name, returnToPipeline: currentPipelineKey, + originTaskId, worklist: [], }); void navigate({ @@ -209,7 +211,7 @@ export const TaskDetails = observer(function TaskDetails({ crossPipelineCount: otherPipelinesPending, }); } else if (otherPipelinesPending > 0) { - launchReconcileOverview(hydratedComponent, originId); + launchReconcileOverview(hydratedComponent, originId, task.$id); } }, ); @@ -475,7 +477,8 @@ export const TaskDetails = observer(function TaskDetails({ LINEAGE_ORIGIN_ANNOTATION, )?.originId; setReconcile(null); - if (originId) launchReconcileOverview(component, originId); + if (originId) + launchReconcileOverview(component, originId, task.$id); }} onConfirm={handleReconcileConfirm} onCancel={() => setReconcile(null)} diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/taskNode.manifest.ts b/src/routes/v2/pages/Editor/nodes/TaskNode/taskNode.manifest.ts index 180fbe52e..19007e13d 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/taskNode.manifest.ts +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/taskNode.manifest.ts @@ -14,6 +14,7 @@ import { hydrateComponentReference } from "@/services/componentService"; import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations"; import type { TaskSpec } from "@/utils/componentSpec"; import { deepClone } from "@/utils/deepClone"; +import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage"; import { TaskDetails } from "./context/TaskDetails/TaskDetails"; @@ -50,9 +51,33 @@ export const taskManifest: NodeTypeManifest = { if (!isTaskSnapshot(snapshot)) return null; const uniqueName = generateUniqueTaskName(spec, snapshot.name); + + // If the source task has no lineage, generate a fresh UUID origin so that + // any subsequent copies of the pasted task carry a stable lineage going + // forward. The PasteLineagePrompt then offers to back-link the source task. + const hasLineage = snapshot.data.annotations.some( + (a) => a.key === LINEAGE_ORIGIN_ANNOTATION, + ); + const extraAnnotations = hasLineage + ? [] + : [ + { + key: LINEAGE_ORIGIN_ANNOTATION, + value: { + originId: + snapshot.data.componentRef.url ?? + snapshot.data.componentRef.digest ?? + crypto.randomUUID(), + originDigest: snapshot.data.componentRef.digest, + originName: snapshot.data.componentRef.name, + }, + }, + ]; + const annotations = Annotations.from([ ...snapshot.data.annotations, { key: EDITOR_POSITION_ANNOTATION, value: position }, + ...extraAnnotations, ]); const clonedComponentRef = deepClone(snapshot.data.componentRef); diff --git a/src/routes/v2/pages/Editor/store/clipboardStore.helpers.ts b/src/routes/v2/pages/Editor/store/clipboardStore.helpers.ts index a0513f144..05c1d5a3e 100644 --- a/src/routes/v2/pages/Editor/store/clipboardStore.helpers.ts +++ b/src/routes/v2/pages/Editor/store/clipboardStore.helpers.ts @@ -10,6 +10,12 @@ import type { UndoGroupable, } from "@/routes/v2/shared/nodes/types"; +export interface CloneResult { + newIds: string[]; + /** Maps each source task's entityId to the newly created task's id. */ + idMap: Map; +} + export function cloneSnapshotsWithBindings( spec: ComponentSpec, snapshots: NodeSnapshot[], @@ -18,7 +24,7 @@ export function cloneSnapshotsWithBindings( undoStore: UndoGroupable, idGen: IncrementingIdGenerator, label: string, -): string[] { +): CloneResult { const newIds: string[] = []; const idMap = new Map(); @@ -42,5 +48,5 @@ export function cloneSnapshotsWithBindings( cloneBindings(spec, bindings, idMap, idGen); }); - return newIds; + return { newIds, idMap }; } diff --git a/src/routes/v2/pages/Editor/store/clipboardStore.ts b/src/routes/v2/pages/Editor/store/clipboardStore.ts index 8a98a4710..8cec683df 100644 --- a/src/routes/v2/pages/Editor/store/clipboardStore.ts +++ b/src/routes/v2/pages/Editor/store/clipboardStore.ts @@ -22,8 +22,28 @@ import type { UndoGroupable, } from "@/routes/v2/shared/nodes/types"; import type { SelectedNode } from "@/routes/v2/shared/store/editorStore"; +import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage"; -import { cloneSnapshotsWithBindings } from "./clipboardStore.helpers"; +import { + type CloneResult, + cloneSnapshotsWithBindings, +} from "./clipboardStore.helpers"; + +/** The source→destination id mapping from the most recent paste or duplicate. */ +export interface PasteContext { + /** Maps source task entityId → newly created task id. */ + idMap: CloneResult["idMap"]; +} + +/** + * Pending copy context: the clipboard write is deferred while the + * CopyLineageModal is open so the user can decide whether to link the + * original task before the copy is committed to the clipboard. + */ +export interface CopyContext { + /** Source task ids that have no lineage annotation. */ + nodeIds: string[]; +} const PASTE_OFFSET = 50; @@ -33,6 +53,15 @@ export class ClipboardStore { @observable.shallow accessor snapshots: NodeSnapshot[] = []; @observable.shallow accessor bindingSnapshots: BindingSnapshot[] = []; @observable accessor pasteOffsetIndex: number = 0; + /** Set after duplicate; cleared when the link-origin modal is dismissed. */ + @observable accessor latestPasteContext: PasteContext | null = null; + /** + * Set when the user initiates a copy (Cmd+C / Copy button) and some of the + * copied tasks lack a lineage annotation. The clipboard write is deferred + * until executeCopy() is called so the user can opt into tracking before + * the copy lands on the clipboard. + */ + @observable accessor pendingCopyContext: CopyContext | null = null; constructor(private undoStore: UndoGroupable) { makeObservable(this); @@ -59,7 +88,68 @@ export class ClipboardStore { this.bindingSnapshots = bindings; this.pasteOffsetIndex = 0; - writeToSystemClipboard(snapshots, bindings); + // Check whether any of the copied tasks lack a lineage annotation. + // If so, defer the clipboard write and show the CopyLineageModal so the + // user can link the original before the copy is committed. + const unlinkedNodeIds = selectedNodes + .filter((node) => { + const task = spec.tasks.find((t) => t.$id === node.id); + return task && !task.annotations.has(LINEAGE_ORIGIN_ANNOTATION); + }) + .map((n) => n.id); + + if (unlinkedNodeIds.length > 0) { + this.pendingCopyContext = { nodeIds: unlinkedNodeIds }; + // Clipboard write is deferred — executeCopy() will do it. + } else { + this.pendingCopyContext = null; + writeToSystemClipboard(snapshots, bindings); + } + } + + /** + * Complete a deferred copy. Called by CopyLineageModal when the user clicks + * "Copy". If `track` is true, the source tasks are stamped with a shared + * lineage origin, the in-memory snapshots are refreshed, and the updated + * snapshots are written to the clipboard. If `track` is false (or the user + * dismissed via Escape/✕), the snapshots are written as-is. + */ + @action executeCopy(track: boolean, spec: ComponentSpec) { + const ctx = this.pendingCopyContext; + + if (track && ctx) { + // Stamp each unlinked source task with a lineage origin. + this.undoStore.withGroup("Link task lineage", () => { + for (const nodeId of ctx.nodeIds) { + const task = spec.tasks.find((t) => t.$id === nodeId); + if (!task) continue; + task.annotations.set(LINEAGE_ORIGIN_ANNOTATION, { + originId: + task.componentRef.url ?? + task.componentRef.digest ?? + crypto.randomUUID(), + originDigest: task.componentRef.digest, + originName: task.componentRef.name, + }); + } + }); + + // Re-take the snapshots so the clipboard data reflects the new lineage. + const updated = this.snapshots.map((snapshot) => { + if (!ctx.nodeIds.includes(snapshot.entityId)) return snapshot; + const manifest = editorRegistry.get(snapshot.$type); + return ( + manifest?.snapshotHandler?.snapshot(spec, snapshot.entityId) ?? + snapshot + ); + }); + this.snapshots = updated; + void writeToSystemClipboard(updated, this.bindingSnapshots); + } else { + void writeToSystemClipboard(this.snapshots, this.bindingSnapshots); + } + + this.pendingCopyContext = null; } async paste( @@ -81,12 +171,16 @@ export class ClipboardStore { this.pasteOffsetIndex += 1; }); - return this.cloneSnapshotsAtPosition( + // Paste does not set latestPasteContext — the user was already given the + // link-origin choice at copy time (CopyLineageModal). Only duplicate uses + // latestPasteContext (it has no separate copy step). + const { newIds } = this.cloneSnapshotsAtPosition( spec, snapshots, bindings, offsetCenter, ); + return newIds; } duplicate(spec: ComponentSpec, selectedNodes: SelectedNode[]): string[] { @@ -104,7 +198,7 @@ export class ClipboardStore { const selectedIds = new Set(selectedNodes.map((n) => n.id)); const bindings = snapshotInternalBindings(spec, selectedIds); - return cloneSnapshotsWithBindings( + const { newIds, idMap } = cloneSnapshotsWithBindings( spec, snapshots, bindings, @@ -116,12 +210,22 @@ export class ClipboardStore { idGen, "Duplicate nodes", ); + runInAction(() => { + this.latestPasteContext = { idMap }; + }); + return newIds; + } + + @action clearPasteContext() { + this.latestPasteContext = null; } @action clear() { this.snapshots = []; this.bindingSnapshots = []; this.pasteOffsetIndex = 0; + this.latestPasteContext = null; + this.pendingCopyContext = null; } private cloneSnapshotsAtPosition( @@ -129,7 +233,7 @@ export class ClipboardStore { snapshots: NodeSnapshot[], bindings: BindingSnapshot[], centerPosition: XYPosition, - ): string[] { + ): CloneResult { const bounds = computeSnapshotBounds(snapshots); const snapshotCenter = { x: bounds.x + bounds.width / 2, diff --git a/src/routes/v2/shared/hooks/useReconcileUIMode.ts b/src/routes/v2/shared/hooks/useReconcileUIMode.ts new file mode 100644 index 000000000..d37247dfe --- /dev/null +++ b/src/routes/v2/shared/hooks/useReconcileUIMode.ts @@ -0,0 +1,22 @@ +import { action, makeObservable, observable } from "mobx"; + +/** + * Shared-layer store that tracks whether the editor is in reconcile UI mode + * (focused canvas, limited chrome). Lives in the shared layer so both: + * - DockArea (shared/windows) — to gate which panels are visible + * - useReconcileFromUrl (pages/Editor/lineage) — to set/clear the flag + * can access it without violating the pages → shared dependency direction. + */ +class ReconcileUIModeStore { + @observable accessor active = false; + + constructor() { + makeObservable(this); + } + + @action setActive(value: boolean): void { + this.active = value; + } +} + +export const reconcileUIModeStore = new ReconcileUIModeStore(); diff --git a/src/routes/v2/shared/windows/DockArea.tsx b/src/routes/v2/shared/windows/DockArea.tsx index f98abc3b8..9fe6817fd 100644 --- a/src/routes/v2/shared/windows/DockArea.tsx +++ b/src/routes/v2/shared/windows/DockArea.tsx @@ -5,6 +5,7 @@ import { BlockStack } from "@/components/ui/layout"; import { VerticalResizeHandle } from "@/components/ui/resize-handle"; import { cn } from "@/lib/utils"; import { focusModeStore } from "@/routes/v2/shared/hooks/useFocusMode"; +import { reconcileUIModeStore } from "@/routes/v2/shared/hooks/useReconcileUIMode"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { CollapsedDockWindowMini } from "./CollapsedDockWindowMini"; @@ -25,6 +26,13 @@ interface DockAreaProps { // without leaving focus mode. const FOCUS_MODE_ALLOWED_WINDOW_IDS = new Set(["context-panel"]); +// During reconcile mode the component library stays open so users can still +// browse and drop components while reviewing a change in context. +const RECONCILE_MODE_ALLOWED_WINDOW_IDS = new Set([ + "context-panel", + "component-library", +]); + export const DockArea = observer(function DockArea({ side }: DockAreaProps) { const { windows } = useSharedStores(); const dockArea = windows.getDockAreaConfig(side); @@ -34,8 +42,10 @@ export const DockArea = observer(function DockArea({ side }: DockAreaProps) { const visibleWindows = windowOrder.filter((id) => { const win = windows.getWindowById(id); if (!win || win.state === "hidden") return false; - if (focusModeStore.active && !FOCUS_MODE_ALLOWED_WINDOW_IDS.has(id)) { - return false; + if (reconcileUIModeStore.active) { + if (!RECONCILE_MODE_ALLOWED_WINDOW_IDS.has(id)) return false; + } else if (focusModeStore.active) { + if (!FOCUS_MODE_ALLOWED_WINDOW_IDS.has(id)) return false; } return true; });