From e4b99ab7759da39c557bfdd6cf4b00bb27ecf7ed Mon Sep 17 00:00:00 2001 From: moosebay Date: Wed, 4 Mar 2026 23:05:56 +0300 Subject: [PATCH 1/5] feat: flow copy/paste with undo/redo support - Add FlowNodesCopy/FlowNodesPaste RPCs with YAML serialization, position preservation, variable remapping, and edge reconstruction - Add client-side undo/redo stack (Cmd+Z / Cmd+Shift+Z) supporting position drag, node delete, edge delete, edge create, and paste - Extract useFlowSelection hook for centralized selection management - Smart paste positioning: same flow offsets 200px, cross-flow centers in viewport - Remove OpenReplay, bump @xyflow/react to 12.10.1 --- packages/client/package.json | 1 - packages/client/src/app/index.tsx | 2 - packages/client/src/app/open-replay.tsx | 42 - .../client/src/pages/flow/agent-panel.tsx | 34 +- packages/client/src/pages/flow/context.tsx | 2 + packages/client/src/pages/flow/edge.tsx | 20 +- packages/client/src/pages/flow/edit.tsx | 200 ++++- packages/client/src/pages/flow/node.tsx | 82 +- packages/client/src/pages/flow/nodes/http.tsx | 6 +- packages/client/src/pages/flow/selection.ts | 42 + packages/client/src/pages/flow/undo.ts | 155 ++++ packages/db/pkg/sqlc/gen/db.go | 20 + packages/db/pkg/sqlc/gen/flow.sql.go | 94 ++ packages/db/pkg/sqlc/queries/flow.sql | 12 + packages/server/cmd/serverrun/serverrun.go | 1 + .../server/internal/api/rflowv2/rflowv2.go | 3 + .../api/rflowv2/rflowv2_copy_paste.go | 847 ++++++++++++++++++ .../api/rflowv2/rflowv2_copy_paste_test.go | 473 ++++++++++ .../internal/api/rflowv2/rflowv2_exec.go | 24 +- .../internal/api/rflowv2/rflowv2_node.go | 25 + .../runner/flowlocalrunner/flowlocalrunner.go | 26 +- .../server/pkg/service/sflow/edge_reader.go | 27 + .../yamlflowsimplev2/converter_node.go | 40 + .../translate/yamlflowsimplev2/exporter.go | 7 +- .../pkg/translate/yamlflowsimplev2/types.go | 2 + packages/spec/api/flow.tsp | 42 + pnpm-lock.yaml | 72 +- pnpm-workspace.yaml | 7 +- 28 files changed, 2144 insertions(+), 164 deletions(-) delete mode 100644 packages/client/src/app/open-replay.tsx create mode 100644 packages/client/src/pages/flow/selection.ts create mode 100644 packages/client/src/pages/flow/undo.ts create mode 100644 packages/server/internal/api/rflowv2/rflowv2_copy_paste.go create mode 100644 packages/server/internal/api/rflowv2/rflowv2_copy_paste_test.go diff --git a/packages/client/package.json b/packages/client/package.json index a298c82e7..6a5a6992b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -33,7 +33,6 @@ "@hookform/resolvers": "catalog:", "@lezer/highlight": "catalog:", "@lezer/lr": "catalog:", - "@openreplay/tracker": "catalog:", "@prettier/plugin-xml": "catalog:", "@react-aria/collections": "catalog:", "@standard-schema/spec": "catalog:", diff --git a/packages/client/src/app/index.tsx b/packages/client/src/app/index.tsx index c1bb1c681..1ed04a8b9 100644 --- a/packages/client/src/app/index.tsx +++ b/packages/client/src/app/index.tsx @@ -11,7 +11,6 @@ import { makeToastQueue } from '@the-dev-tools/ui/toast'; import { ApiCollections, ApiTransport } from '~/shared/api'; import { runtimeAtom } from '~/shared/lib/runtime'; import { RouterContext } from './context'; -import { startOpenReplay } from './open-replay'; import { router } from './router'; import { initUmami } from './umami'; @@ -23,7 +22,6 @@ const appAtom = runtimeAtom.atom( // Telemetry startup should never block app rendering. void Runtime.runPromise(runtime)(initUmami).catch(() => undefined); - void Runtime.runPromise(runtime)(startOpenReplay).catch(() => undefined); yield* ApiCollections; const transport = yield* ApiTransport; diff --git a/packages/client/src/app/open-replay.tsx b/packages/client/src/app/open-replay.tsx deleted file mode 100644 index 8305e8c20..000000000 --- a/packages/client/src/app/open-replay.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import OpenReplayTracker from '@openreplay/tracker'; -import { Config, Data, Effect, Option, pipe, Redacted } from 'effect'; - -export class StartOpenReplayError extends Data.TaggedError('StartOpenReplayError')<{ reason: string }> {} - -export const startOpenReplay = Effect.gen(function* () { - const configNamespace = Config.nested('PUBLIC_OPEN_REPLAY'); - - const track = yield* pipe( - Config.boolean('TRACK'), - configNamespace, - Config.orElse(() => Config.succeed(false)), - ); - if (!track) return; - - const projectKey = yield* pipe(Config.redacted('PROJECT_KEY'), configNamespace); - const tracker = new OpenReplayTracker({ - projectKey: Redacted.value(projectKey), - - __DISABLE_SECURE_MODE: true, - - network: { - captureInIframes: true, - capturePayload: true, - failuresOnly: false, - ignoreHeaders: false, - sessionTokenHeader: false, - }, - }); - - const sessionName = yield* pipe(Config.string('SESSION_NAME'), configNamespace, Config.option); - Option.map(sessionName, (_) => void tracker.setMetadata('session-name', _)); - - const userId = yield* pipe(Config.string('USER_ID'), configNamespace, Config.option); - Option.map(userId, (_) => void tracker.setUserID(_)); - - const result = yield* Effect.promise(() => tracker.start()); - if (!result.success) return yield* Effect.fail(new StartOpenReplayError({ reason: result.reason })); - yield* Effect.logInfo('Tracking started', { ...result, sessionName, userId }); - - yield* Effect.addFinalizer(() => Effect.sync(() => tracker.stop())); -}); diff --git a/packages/client/src/pages/flow/agent-panel.tsx b/packages/client/src/pages/flow/agent-panel.tsx index 118484350..64fdba663 100644 --- a/packages/client/src/pages/flow/agent-panel.tsx +++ b/packages/client/src/pages/flow/agent-panel.tsx @@ -1,5 +1,4 @@ import { eq, useLiveQuery } from '@tanstack/react-db'; -import * as XF from '@xyflow/react'; import { Ulid } from 'id128'; import { FormEvent, KeyboardEvent, use, useEffect, useMemo, useRef, useState } from 'react'; import { FiArrowUp, FiChevronUp, FiEdit, FiSettings, FiX } from 'react-icons/fi'; @@ -12,7 +11,7 @@ import { type Message, type ToolCall, useAgentChat } from '~/features/agent'; import { type AgentProvider, useAgentProviderKey } from '~/features/agent/use-agent-provider-key'; import { useApiCollection } from '~/shared/api'; import { FlowContext } from './context'; -import { nodeClientCollection } from './node'; +import { useFlowSelection } from './selection'; // --------------------------------------------------------------------------- // Tool call display helpers @@ -87,10 +86,7 @@ const PROVIDER_OPTIONS: Record< export const AgentPanel = () => { const { flowId, setAgentPanelOpen } = use(FlowContext); const { apiKey, provider, setApiKey, setProvider } = useAgentProviderKey(); - const selectedNodeIds = XF.useStore( - (s) => s.nodes.filter((n) => n.selected).map((n) => n.id), - (a, b) => a.length === b.length && a.every((id, i) => id === b[i]), - ); + const { deselectAll, deselectNodes, selectedNodeIds } = useFlowSelection(); const { cancel, clearMessages, error, isLoading, messages, sendMessage, streamingContent } = useAgentChat({ apiKey, flowId, @@ -231,7 +227,9 @@ export const AgentPanel = () => { className={tw`m-2 mt-0 rounded-[4px] border border-(--border-1) bg-(--surface-4) px-2.5 py-1.5`} data-agent-composer > - {selectedNodeIds.length > 0 && } + {selectedNodeIds.length > 0 && ( + + )}