diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx index b7a08d26b..290448303 100644 --- a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx @@ -15,6 +15,7 @@ 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 { reconcileModeStore } from "@/routes/v2/pages/Editor/lineage/reconcileModeStore"; 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"; @@ -62,14 +63,30 @@ export const FlowCanvas = observer(function FlowCanvas({ const isDetailedView = useIsDetailedView(); const { - displayNodes, + displayNodes: rawDisplayNodes, displayEdges, onEdgeClick, rfOnNodesChange, rfOnEdgesChange, - selectionBehavior, + selectionBehavior: rawSelectionBehavior, } = useFlowCanvasState({ spec, metaKeyPressed, isConnecting }); + // During reconcile mode: boost the current task's zIndex above all others so + // it's always visible regardless of user-set stacking order, and lock canvas + // selection to that task so clicks don't shift the blue ring elsewhere. + const reconcileTaskId = reconcileModeStore.currentReconcileTaskId; + const isReconciling = reconcileModeStore.active; + + const displayNodes = reconcileTaskId + ? rawDisplayNodes.map((n) => + n.id === reconcileTaskId ? { ...n, zIndex: 10000 } : n, + ) + : rawDisplayNodes; + + const selectionBehavior = isReconciling + ? { ...rawSelectionBehavior, onSelectionChange: () => {} } + : rawSelectionBehavior; + const onBeforeDelete = useFlowCanvasOnBeforeDelete(spec); useFitViewOnFocus(); @@ -116,7 +133,7 @@ export const FlowCanvas = observer(function FlowCanvas({ onViewportChange={handleViewportChange} onBeforeDelete={onBeforeDelete} connectionLineComponent={ConnectionLine} - deleteKeyCode={["Delete", "Backspace"]} + deleteKeyCode={isReconciling ? null : ["Delete", "Backspace"]} className={cn( shiftKeyPressed && !isConnecting && "cursor-crosshair", !isDetailedView && "connections-disabled", diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/hooks/useFlowCanvasOnBeforeDelete.ts b/src/routes/v2/pages/Editor/components/FlowCanvas/hooks/useFlowCanvasOnBeforeDelete.ts index 5a6c5679a..5e4779dda 100644 --- a/src/routes/v2/pages/Editor/components/FlowCanvas/hooks/useFlowCanvasOnBeforeDelete.ts +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/hooks/useFlowCanvasOnBeforeDelete.ts @@ -5,6 +5,7 @@ import { type FlowCanvasDeleteDeps, runFlowCanvasOnBeforeDelete, } from "@/routes/v2/pages/Editor/components/FlowCanvas/canvasDeleteSelection"; +import { reconcileModeStore } from "@/routes/v2/pages/Editor/lineage/reconcileModeStore"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useNodeRegistry } from "@/routes/v2/shared/nodes/NodeRegistryContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; @@ -12,6 +13,7 @@ import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; /** * `onBeforeDelete` for React Flow: applies editor/spec deletion and aborts RF’s * internal removal so controlled `nodes`/`edges` stay spec-driven. + * All deletion is blocked while reconcile mode is active. */ export function useFlowCanvasOnBeforeDelete( spec: ComponentSpec | null, @@ -20,8 +22,11 @@ export function useFlowCanvasOnBeforeDelete( const { undo } = useEditorSession(); const registry = useNodeRegistry(); - return (params) => - runFlowCanvasOnBeforeDelete( + return (params) => { + if (reconcileModeStore.active) { + return Promise.resolve({ nodes: [], edges: [] }); + } + return runFlowCanvasOnBeforeDelete( { spec, undo, @@ -31,4 +36,5 @@ export function useFlowCanvasOnBeforeDelete( } satisfies FlowCanvasDeleteDeps, params, ); + }; } diff --git a/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx b/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx index cbbbc47cf..0fd205bd1 100644 --- a/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx +++ b/src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx @@ -15,7 +15,7 @@ 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"; +import { LINEAGE_EXCLUDE_ANNOTATION } from "@/utils/lineage"; /** * Modal shown when the user initiates a copy (Cmd+C / Copy button) and the @@ -46,15 +46,16 @@ export const CopyLineageModal = observer(function CopyLineageModal() { ? `"${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) => { + // Defensive check: if all tasks already have an explicit tracking choice + // (exclude_from_reconcile present), skip the modal and copy immediately. + // Must match the same condition as ClipboardStore.copy() — absence of + // exclude_from_reconcile means "never chose", not absence of origin. + const hasAnyUndecided = ctx.nodeIds.some((id) => { const task = spec.tasks.find((t) => t.$id === id); - return task && !task.annotations.has(LINEAGE_ORIGIN_ANNOTATION); + return task && !task.annotations.has(LINEAGE_EXCLUDE_ANNOTATION); }); - if (!hasAnyUnlinked) { - // All tasks are already linked — just commit the copy silently. + if (!hasAnyUndecided) { clipboard.executeCopy(false, spec); return null; } diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx index 2f0dc86d1..735e55a87 100644 --- a/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx +++ b/src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx @@ -6,6 +6,11 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Text } from "@/components/ui/typography"; import { APP_ROUTES } from "@/routes/router"; import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions"; @@ -39,14 +44,17 @@ import { findTaskContext } from "./findTaskContext"; import { reconcileModeStore } from "./reconcileModeStore"; /** - * Drives the in-canvas reconcile experience when reconcile mode is active: - * holds autosave, stages the edited component onto matching tasks in-memory - * (rendered immediately, fully undoable), spotlights them, and surfaces a - * node-anchored "Finish Reconciling" button (the explicit commit) plus a banner. + * Drives the in-canvas reconcile experience when reconcile mode is active. * - * Rendered inside `` 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). + * All matching tasks are staged simultaneously (in-memory, nothing persisted). + * The user reviews them one at a time — each has a "Mark Done" button that + * works like a checkbox. Previous/Next navigate freely. When every task is + * marked done the pipeline is saved and the user returns to the overview. + * Cancel discards all staged changes. + * + * Issue 4 fix: stagedSessionRef is explicitly cleared in leave() to prevent + * "Nothing to reconcile" on re-entry when the same FlowCanvas key is reused + * (i.e. the same pipeline is navigated to a second time without re-mounting). */ export const ReconcileModeController = observer( function ReconcileModeController() { @@ -59,6 +67,9 @@ export const ReconcileModeController = observer( const [ready, setReady] = useState(false); const [matchTaskIds, setMatchTaskIds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + // Checkbox model: every task must be marked before finishing. + const [doneTaskIds, setDoneTaskIds] = useState>(new Set()); const stagedSessionRef = useRef(null); useEffect(() => { @@ -66,6 +77,8 @@ export const ReconcileModeController = observer( stagedSessionRef.current = null; setReady(false); setMatchTaskIds([]); + setCurrentIndex(0); + setDoneTaskIds(new Set()); } }, [session]); @@ -80,12 +93,7 @@ export const ReconcileModeController = observer( }); if (cancelled || !component) return; - // 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( @@ -105,10 +113,14 @@ export const ReconcileModeController = observer( }); editor.setPendingFocusNode(matches[0].taskId); editor.setSpotlightNode(matches[0].taskId); + editor.selectNode(matches[0].taskId, "task"); + reconcileModeStore.setCurrentReconcileTaskId(matches[0].taskId); } if (!cancelled) { setMatchTaskIds(matches.map((m) => m.taskId)); + setCurrentIndex(0); + setDoneTaskIds(new Set()); setReady(true); } })(); @@ -116,11 +128,18 @@ export const ReconcileModeController = observer( return () => { cancelled = true; }; - // Stage once per session; deps kept stable intentionally. }, [session?.sessionId, spec]); if (!session || !ready) return null; + const count = matchTaskIds.length; + const currentTaskId = matchTaskIds[currentIndex] ?? null; + const doneCount = doneTaskIds.size; + const allDone = doneCount === count && count > 0; + const currentIsDone = currentTaskId + ? doneTaskIds.has(currentTaskId) + : false; + const returnToOverview = () => navigate({ to: APP_ROUTES.EDITOR_V2_PIPELINE, @@ -136,26 +155,121 @@ export const ReconcileModeController = observer( }; const leave = async () => { - // Leave without saving — the staged change is discarded on reload. + // Clear stagedSessionRef so re-entering reconcile mode for the same + // pipeline will re-run staging (the FlowCanvas key may not change). + stagedSessionRef.current = null; reconcileModeStore.exit(); await returnToOverview(); }; - const count = matchTaskIds.length; + const goTo = (index: number) => { + const clamped = Math.max(0, Math.min(index, count - 1)); + setCurrentIndex(clamped); + const taskId = matchTaskIds[clamped]; + if (taskId) { + editor.setPendingFocusNode(taskId); + editor.setSpotlightNode(taskId); + editor.selectNode(taskId, "task"); + reconcileModeStore.setCurrentReconcileTaskId(taskId); + } + }; + + /** Find the next task that hasn't been marked done, searching forward. */ + const findNextUndone = (fromIndex: number): number | null => { + for (let i = 1; i < count; i++) { + const idx = (fromIndex + i) % count; + if (!doneTaskIds.has(matchTaskIds[idx])) return idx; + } + return null; + }; + + const toggleDone = () => { + if (!currentTaskId) return; + const newDone = new Set(doneTaskIds); + + if (currentIsDone) { + // Unmark — user changed their mind. + newDone.delete(currentTaskId); + setDoneTaskIds(newDone); + } else { + // Mark done. + newDone.add(currentTaskId); + setDoneTaskIds(newDone); + + if (newDone.size === count) { + // All tasks reviewed — finish. + void finish(); + } else { + // Advance to the next undone task. + const nextIdx = findNextUndone(currentIndex); + if (nextIdx !== null) goTo(nextIdx); + } + } + }; return ( <> - {count > 0 && ( + {currentTaskId && ( - + + {count > 1 && ( + + + + + Previous task + + )} + + {count === 1 ? ( + + ) : ( + + )} + + {count > 1 && ( + + + + + Next task + + )} + )} @@ -168,8 +282,14 @@ export const ReconcileModeController = observer( {count > 0 ? ( - Reconciling {session.targetName} · {count}{" "} - {count === 1 ? "task" : "tasks"} staged + Reconciling {session.targetName} + {count > 1 && ( + <> + {" "} + · {doneCount}/{count} done + {!allDone && ` · task ${currentIndex + 1} of ${count}`} + + )} ) : ( 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 c7c200451..1ec038950 100644 --- a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx +++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx @@ -61,6 +61,7 @@ export function ReconcileOverview({ const { autoSave } = useEditorSession(); const [targets, setTargets] = useState(null); + const [copiedKey, setCopiedKey] = useState(null); useEffect(() => { let cancelled = false; @@ -69,7 +70,20 @@ export function ReconcileOverview({ session.originId, session.targetDigest, ); - if (!cancelled) setTargets(results); + if (!cancelled) { + // Exclude the root-level of the origin pipeline — the user just did + // "Update this task" there, so it already appears reconciled and adds + // noise. Subgraph-level targets from the origin pipeline are kept. + setTargets( + results.filter( + (t) => + !( + t.storageKey === session.returnToPipeline && + t.subgraphPath.length === 0 + ), + ), + ); + } })(); return () => { cancelled = true; @@ -93,6 +107,8 @@ export function ReconcileOverview({ const copyLink = (storageKey: string) => { const url = `${window.location.origin}/editor-v2/${encodeURIComponent(storageKey)}`; void navigator.clipboard.writeText(url); + setCopiedKey(storageKey); + setTimeout(() => setCopiedKey(null), 1200); }; return ( @@ -131,6 +147,9 @@ export function ReconcileOverview({ const done = target.pendingCount === 0; const author = formatAuthor(target.author); const label = targetLabel(target); + const isCurrent = + target.storageKey === session.returnToPipeline && + target.subgraphPath.length === 0; return (
- {/* Label (pipeline › subgraph path) + author */} + {/* Label (pipeline › subgraph path) + current + author */} {label} + {isCurrent && ( + + current + + )} {author && ( {author} @@ -172,7 +196,11 @@ export function ReconcileOverview({