mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
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>
284 lines
8.4 KiB
Svelte
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>
|