Files
windmill/frontend/src/lib/components/flows/FlowEditor.svelte
T
Guilhem LemouelandClaude Opus 4.8 acf9ac2b26 fix(editors): decide chat preservation from route paths, not editor state
navStaysInEditor used the open entity's path as the add->edit promotion signal,
but that path is undefined for a new/draft flow (flowOptions.path = savedFlow?.path),
so cross-flow navigation wrongly preserved the chat ("never reset"). Decide from
the beforeNavigate from/to pathnames instead, which are always concrete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:14:57 +02:00

284 lines
8.4 KiB
Svelte

<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
import WindmillIcon from '../icons/WindmillIcon.svelte'
import { Skeleton } from '../common'
import { getContext, onDestroy, onMount, setContext } from 'svelte'
import { beforeNavigate } from '$app/navigation'
import type { FlowEditorContext } from './types'
import { writable } from 'svelte/store'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import type { Flow, Job } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
import {
AIChatManager,
aiChatManager as singletonAiChatManager,
AIMode
} from '../copilot/chat/AIChatManager.svelte'
import { navStaysInEditor } from '../copilot/chat/editorNav'
import type { GraphModuleState } from '../graph'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import type { ModulesTestStates } from '../modulesTest.svelte'
import type { StateStore } from '$lib/utils'
import type { FlowOptions } from '../copilot/chat/ContextManager.svelte'
import { extractAllModules } from '../copilot/chat/shared'
import type { Snippet } from 'svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
interface Props {
loading: boolean
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
disabledFlowInputs?: boolean
smallErrorHandler?: boolean
newFlow?: boolean
showJobStatus?: boolean
savedFlow?:
| (Flow & {
draft?: Flow | undefined
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: ((moduleId: string, key: string) => void) | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
aiChatOpen?: boolean
showFlowAiButton?: boolean
toggleAiChat?: () => void
localModuleStates?: Record<string, GraphModuleState>
testModuleStates?: ModulesTestStates
isOwner?: boolean
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
isRunning?: boolean
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
individualStepTests?: boolean
job?: Job
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
onDelete?: (id: string) => void
flowHasChanged?: boolean
previewOpen: boolean
graphOverlay?: Snippet
}
let {
loading,
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
disabledFlowInputs = false,
smallErrorHandler = false,
showJobStatus = false,
newFlow = false,
savedFlow = undefined,
onDeployTrigger = () => {},
onTestUpTo = undefined,
onEditInput = undefined,
forceTestTab,
highlightArg,
localModuleStates = {},
testModuleStates = undefined,
aiChatOpen,
showFlowAiButton,
toggleAiChat,
isOwner,
onTestFlow,
isRunning,
onCancelTestFlow,
onOpenPreview,
onHideJobStatus,
individualStepTests = false,
job,
suspendStatus,
onDelete,
flowHasChanged,
previewOpen,
graphOverlay
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
// When the graph pane is narrow, fall back to a top-centered overlay so the
// preview buttons don't overlap the rightmost node ports (matches the dev
// page layout).
let graphPaneWidth = $state(0)
const compactGraphOverlay = $derived(graphPaneWidth > 0 && graphPaneWidth < 800)
export function isNodeVisible(nodeId: string): boolean {
return flowModuleSchemaMap?.isNodeVisible(nodeId) ?? false
}
export function enableNotes(): void {
flowModuleSchemaMap?.enableNotes?.()
}
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
})
$effect(() => {
const options: FlowOptions = {
currentFlow: flowStore.val,
lastDeployedFlow: savedFlow,
lastSavedFlow: savedFlow?.draft,
path: savedFlow?.path,
modules: extractAllModules(flowStore.val.value.modules)
}
aiChatManager.flowOptions = options
})
// Clear the chat only when the user actually LEAVES this flow's editor (a real
// navigation to a different flow or out of the editor). Default false so
// non-navigation unmount/remounts — e.g. the edit page reloading after the
// /flows/add → /flows/edit/{path} promotion, or a selected-step query change —
// preserve the FLOW-mode conversation. Those remounts fire no beforeNavigate,
// so leaveOnDestroy stays false and the chat survives; the fresh onMount then
// sees mode is still FLOW and skips its clearing saveAndClear.
let leaveOnDestroy = $state(false)
beforeNavigate(({ from, to }) => {
// Recompute on every navigation (both branches) so a stale decision never
// lingers. Decided from the route pathnames so a new/draft flow (whose
// flowOptions.path is undefined) still clears when navigating cross-flow.
leaveOnDestroy = !navStaysInEditor(
from?.url.pathname ?? '',
to?.url.pathname ?? '',
'/flows/add',
'/flows/edit/'
)
})
onMount(() => {
if (!sessionScopedManager) {
// A preserved intra-editor remount leaves mode === FLOW with the
// conversation intact; skip saveAndClear so we don't blow it away.
if (aiChatManager.mode !== AIMode.FLOW) {
aiChatManager.saveAndClear()
}
aiChatManager.changeMode(AIMode.FLOW)
}
})
onDestroy(() => {
aiChatManager.flowOptions = undefined
if (!sessionScopedManager && leaveOnDestroy) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
}
})
</script>
<div
id="flow-editor"
class={'h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
use:triggerableByAI={{
id: 'flow-editor',
description: 'Component to edit a flow'
}}
>
<Splitpanes>
<Pane size={50} minSize={15} class="h-full relative z-0">
<div
bind:clientWidth={graphPaneWidth}
class="grow overflow-hidden bg-gray h-full bg-surface-secondary relative"
>
{#if graphOverlay}
<div
class="absolute z-30 flex gap-2 {compactGraphOverlay
? 'top-14 left-1/2 -translate-x-1/2'
: 'top-2 right-2'}"
>
{@render graphOverlay()}
</div>
{/if}
{#if loading}
<div class="p-2 pt-10">
{#each new Array(6) as _}
<Skeleton layout={[[2], 1.5]} />
{/each}
</div>
{:else if flowStore.val.value.modules}
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
controlsPosition={compactGraphOverlay ? 'bottom' : 'top'}
{disableStaticInputs}
{disableTutorials}
{disableAi}
{disableSettings}
{smallErrorHandler}
{newFlow}
{showJobStatus}
on:reload
on:generateStep={({ detail }) => {
if (!aiChatManager.open) {
aiChatManager.openChat()
}
aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions)
}}
{onTestUpTo}
{onEditInput}
{localModuleStates}
{testModuleStates}
{aiChatOpen}
{showFlowAiButton}
{toggleAiChat}
{isOwner}
{onTestFlow}
{isRunning}
{onCancelTestFlow}
{onOpenPreview}
{onHideJobStatus}
{individualStepTests}
flowJob={job}
{suspendStatus}
{onDelete}
{flowHasChanged}
/>
{/if}
</div>
</Pane>
<Pane class="relative z-10" size={50} minSize={20}>
{#if loading}
<div class="w-full h-full">
<div class="block m-auto pt-40 w-10">
<WindmillIcon height="40px" width="40px" spin="fast" />
</div>
</div>
{:else}
<FlowEditorPanel
{disabledFlowInputs}
{newFlow}
{savedFlow}
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onTestFlow}
{job}
{isOwner}
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/if}
</Pane>
{#if !disableAi}
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
{/if}
</Splitpanes>
</div>