mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 16:03:26 +00:00
* feat: edit variables, resources and triggers in their own session tab Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take a trigger's flow kind from its config and name the written trigger Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: one operating-workspace context for editors acting on a session's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reload page item tabs only in sessions acting on the tool's workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve open in workspace for every preview tab kind in one place Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: guard every component under a session editor against the navigation workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: show a page item tab whose item is gone instead of mounting its editor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow a saved page item in the tab's own workspace and restore in-frame rows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: close a page item tab from its editor header Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge trigger permissions by the user acting in the session workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: judge every session-editor permission by the user acting in that workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep session permissions reactive to the acting role and restore tabs as last seen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: default Path's owner controls to the user acting in its workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
564 lines
20 KiB
Svelte
564 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
|
|
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
|
|
import { agentEditorTarget, type AgentEditorTarget } from './agentEditorStore.svelte'
|
|
import AgentEditorModal from './content/AgentEditorModal.svelte'
|
|
import { repointLinkedAgent } from './linkedAgentDrafts'
|
|
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
|
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
|
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
|
import { Skeleton } from '../common'
|
|
import { getContext, onDestroy, onMount, setContext, untrack } from 'svelte'
|
|
import type { FlowEditorContext, FlowPanelDetachContext } from './types'
|
|
import { getOverlayHost } from '$lib/components/common/overlayHost.svelte'
|
|
import Portal from '$lib/components/Portal.svelte'
|
|
import { isFlowLevelPanelTarget } from '$lib/components/graph/selectionUtils.svelte'
|
|
import { useFlowPanelMode } from './flowPanelMode.svelte'
|
|
import { useFlowPanelPlacementTelemetry } from './flowEditorTelemetry'
|
|
|
|
import { get, 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 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'
|
|
import { Button } from '../common'
|
|
import { MousePointerClick, X } from 'lucide-svelte'
|
|
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
|
|
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
|
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
|
|
import {
|
|
useOperatingUser,
|
|
useOperatingWorkspace
|
|
} from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
const operatingUser = useOperatingUser()
|
|
const actingUser = $derived(operatingUser.current)
|
|
const { flowStore, selectionManager, pathStore, opWorkspace } =
|
|
getContext<FlowEditorContext>('FlowEditorContext')
|
|
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
|
|
// editors can hold the same path at once. Both halves are needed to tell them apart.
|
|
let editorWorkspace = $derived(opWorkspace?.() ?? $operatingWorkspace)
|
|
function targetWorkspace(t: AgentEditorTarget): string | undefined {
|
|
return t.workspace ?? $operatingWorkspace
|
|
}
|
|
|
|
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
|
|
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
|
|
|
|
interface Props {
|
|
loading: boolean
|
|
disableStaticInputs?: 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
|
|
sessionOpen?: OpenInSessionSource
|
|
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
|
|
/** Allow the step-details pane to open as a modal. Whitelabel embeds turn this off
|
|
* to keep the classic always-docked pane. */
|
|
modalPanel?: boolean
|
|
}
|
|
|
|
let {
|
|
loading,
|
|
disableStaticInputs = 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,
|
|
sessionOpen,
|
|
isOwner,
|
|
onTestFlow,
|
|
isRunning,
|
|
onCancelTestFlow,
|
|
onOpenPreview,
|
|
onHideJobStatus,
|
|
individualStepTests = false,
|
|
job,
|
|
suspendStatus,
|
|
onDelete,
|
|
flowHasChanged,
|
|
previewOpen,
|
|
graphOverlay,
|
|
modalPanel = true
|
|
}: Props = $props()
|
|
|
|
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
|
|
|
|
// 'docked' = normal split pane; 'modal' = graph full-width, panel in a modal opened by
|
|
// double-clicking a node. The controller resolves it from the user's Auto/Attached/
|
|
// Detached preference and the width measured below.
|
|
const panelController = useFlowPanelMode({ enabled: () => modalPanel })
|
|
const panelMode = $derived(panelController.mode)
|
|
let panelModalOpen = $state(false)
|
|
|
|
// Owned by FlowBuilder: this component is inside a `{#key}` that rebuilds it on a reload,
|
|
// and the crossing count belongs to the editing session rather than to one mount.
|
|
const placementTelemetry = useFlowPanelPlacementTelemetry()
|
|
$effect(() => {
|
|
placementTelemetry.observe(panelController.preference, panelMode, panelController.measured)
|
|
})
|
|
|
|
// Auto can move the panel back into the pane under a modal that is open — leaving it
|
|
// open would keep an overlay registered for a modal nothing renders, swallowing Escape.
|
|
$effect(() => {
|
|
if (panelMode === 'docked' && untrack(() => panelModalOpen)) {
|
|
panelModalOpen = false
|
|
}
|
|
})
|
|
|
|
let panelDisposable: Disposable | undefined = $state(undefined)
|
|
// Disposable joins the stack through its methods, not by watching `open` — same sync
|
|
// as Drawer and Modal, so setting `panelModalOpen` anywhere still registers the overlay.
|
|
$effect(() => {
|
|
panelModalOpen
|
|
untrack(() => {
|
|
panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer()
|
|
})
|
|
})
|
|
|
|
const overlayHost = getOverlayHost()
|
|
const modalHost = $derived(overlayHost?.el())
|
|
|
|
// Only nodes that can take the selection, or the modal would open on whatever was
|
|
// selected before — asset and note nodes are deliberately unselectable.
|
|
//
|
|
// The In/Out bar sits inside the node but is a picker of its own: it toggles open and
|
|
// shut on click, so opening then closing it is a double-click the graph must not read
|
|
// as "show me this step".
|
|
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
|
|
const target = e.target as HTMLElement | null
|
|
if (target?.closest('[data-prop-picker]')) return null
|
|
return target?.closest('.svelte-flow__node.selectable') ?? null
|
|
}
|
|
|
|
function openPanelModalFromGraph(e: MouseEvent) {
|
|
if (selectableNodeAt(e)) {
|
|
panelModalOpen = true
|
|
}
|
|
}
|
|
|
|
// A click on the step that is already selected is the second half of "select it, then
|
|
// show it". Read in the capture phase: by the time the click bubbles here the graph has
|
|
// applied its own selection, so a first click would look indistinguishable from this.
|
|
let clickStartedOnSelected = false
|
|
function noteSelectionBeforeClick(e: MouseEvent) {
|
|
const node = selectableNodeAt(e)
|
|
clickStartedOnSelected =
|
|
Boolean(node?.classList.contains('selected')) && selectionManager.selectedIds.length === 1
|
|
}
|
|
|
|
function openPanelModalIfReselected(e: MouseEvent) {
|
|
if (clickStartedOnSelected && selectableNodeAt(e)) {
|
|
panelModalOpen = true
|
|
}
|
|
}
|
|
|
|
// In modal mode a step's editor is a click or two away but invisible until then —
|
|
// keep a standing hint whenever the graph is showing (modal closed).
|
|
const showStepHint = $derived.by(() => panelMode === 'modal' && !panelModalOpen)
|
|
const stepHintText = $derived.by(() => {
|
|
const ids = selectionManager.selectedIds
|
|
return ids.length === 1 && !isFlowLevelPanelTarget(ids[0])
|
|
? 'Click the selected step to explore its content'
|
|
: 'Double click a step to explore its content'
|
|
})
|
|
|
|
// 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?.()
|
|
}
|
|
|
|
const flowPropPickerConfig = writable<FlowPropPickerConfig | undefined>(undefined)
|
|
// Closing the modal unmounts the panel that started a graph connect, but the config
|
|
// outlives it — a later pick would run a closure over a step nobody is editing.
|
|
$effect(() => {
|
|
if (!panelModalOpen) {
|
|
flowPropPickerConfig.set(undefined)
|
|
}
|
|
})
|
|
|
|
setContext<PropPickerContext>('PropPickerContext', {
|
|
flowPropPickerConfig,
|
|
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined),
|
|
// The agent editor is a dialog over the same graph, so a connect started inside it has the
|
|
// same closure hazard as one started from the modal panel. Only this flow's own, on the same
|
|
// rule the mount below claims one by: a session keeps every visited tab alive, and a target
|
|
// belonging to another of them is not a dialog over this graph.
|
|
inModalPanel: () => {
|
|
if (panelMode === 'modal') return true
|
|
const t = agentEditorTarget()
|
|
return (
|
|
t !== undefined &&
|
|
t.host?.flowPath === get(pathStore) &&
|
|
targetWorkspace(t) === editorWorkspace
|
|
)
|
|
}
|
|
})
|
|
|
|
// Read by graph step items (VirtualItem) to show a per-step "explore" hint on hover,
|
|
// since in modal mode a step's editor is hidden until a double-click, or a click on the
|
|
// step that is already selected.
|
|
setContext<() => boolean>('flowGraphStepExploreHint', () => panelMode === 'modal')
|
|
|
|
// The panel's chrome lives inline in its card header (no dedicated row); panels without
|
|
// a card header get FlowEditor's fallback strip instead, driven by the claim count.
|
|
let detachClaims = $state(0)
|
|
setContext<FlowPanelDetachContext>('flowPanelDetach', {
|
|
claim: () => {
|
|
detachClaims++
|
|
return () => detachClaims--
|
|
},
|
|
modalOpen: () => modalPanel && panelMode === 'modal' && panelModalOpen,
|
|
close: () => (panelModalOpen = false),
|
|
enabled: () => modalPanel,
|
|
preference: () => panelController.preference,
|
|
setPreference: (preference) => {
|
|
// Picking the row that is already active is not a move, and counting it would
|
|
// report a placement being forced that the panel was already in.
|
|
if (preference === panelController.preference) return
|
|
// Moving the panel must not lose what it was showing: docked, it is always on
|
|
// screen, so the modal it becomes has to open on arrival. The reverse is handled
|
|
// by the effect above, which closes a modal that is no longer rendered.
|
|
const wasVisible = panelMode === 'docked' || panelModalOpen
|
|
placementTelemetry.forced(preference, panelMode)
|
|
panelController.preference = preference
|
|
panelModalOpen = panelController.mode === 'modal' && wasVisible
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
const options: FlowOptions = {
|
|
currentFlow: flowStore.val,
|
|
lastDeployedFlow: savedFlow,
|
|
lastSavedFlow: savedFlow?.draft,
|
|
path: savedFlow?.path,
|
|
modules: extractAllModules(flowStore.val.value.modules)
|
|
}
|
|
aiChatManager.flowOptions = options
|
|
})
|
|
|
|
// The step exists but is empty, so name it: a GLOBAL-mode request carries no
|
|
// implicit "current step" the way the old SCRIPT-mode generateStep did.
|
|
function stepInstructionsPrompt(moduleId: string, instructions: string): string {
|
|
return `Write the code for step \`${moduleId}\` of the flow open in the editor:\n\n${instructions}`
|
|
}
|
|
|
|
onMount(() => {
|
|
if (modalPanel) {
|
|
selectionManager.setOnSelectIntent((id, opts) => {
|
|
if (opts?.openPanel === false) return
|
|
if (panelMode === 'modal' && (opts?.openPanel || isFlowLevelPanelTarget(id))) {
|
|
panelModalOpen = true
|
|
}
|
|
})
|
|
}
|
|
if (!sessionScopedManager) {
|
|
aiChatManager.saveAndClear()
|
|
aiChatManager.changeMode(AIMode.FLOW)
|
|
}
|
|
})
|
|
|
|
onDestroy(() => {
|
|
aiChatManager.flowOptions = undefined
|
|
if (modalPanel) {
|
|
selectionManager.setOnSelectIntent(undefined)
|
|
}
|
|
if (!sessionScopedManager) {
|
|
aiChatManager.saveAndClear()
|
|
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
{#snippet panelBody()}
|
|
<FlowEditorPanel
|
|
{disabledFlowInputs}
|
|
{newFlow}
|
|
{savedFlow}
|
|
enableAi={!disableAi}
|
|
on:applyArgs
|
|
on:testWithArgs
|
|
{onDeployTrigger}
|
|
{forceTestTab}
|
|
{highlightArg}
|
|
{onTestFlow}
|
|
{job}
|
|
{isOwner}
|
|
{suspendStatus}
|
|
onOpenDetails={onOpenPreview}
|
|
{previewOpen}
|
|
{flowModuleSchemaMap}
|
|
/>
|
|
{/snippet}
|
|
|
|
<div
|
|
bind:clientWidth={null, (w) => panelController.measure(w)}
|
|
id="flow-editor"
|
|
class={'relative 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={panelMode === 'docked' ? 50 : 100} minSize={15} class="h-full relative z-0">
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
bind:clientWidth={graphPaneWidth}
|
|
ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined}
|
|
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
|
|
onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined}
|
|
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}
|
|
{disableAi}
|
|
{disableSettings}
|
|
{smallErrorHandler}
|
|
{newFlow}
|
|
{showJobStatus}
|
|
on:reload
|
|
on:generateStep={({ detail }) => {
|
|
// The step is already inserted; the prompt describes what it should
|
|
// contain. Hand it to a session opened on that step rather than the
|
|
// docked chat, which sessions leave unmounted. Sent on arrival: the
|
|
// user already said what they wanted in the description field.
|
|
if (
|
|
!sessionScopedManager &&
|
|
sessionOpen &&
|
|
prefersSessionHandoff(actingUser?.operator)
|
|
) {
|
|
void openSourceInSession(sessionOpen, {
|
|
previewParams: { selected: detail.moduleId },
|
|
seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions),
|
|
autoSend: true
|
|
})
|
|
return
|
|
}
|
|
// Already in a session: its chat is on screen, so ask it directly.
|
|
// Not `generateStep` — that forces the request into SCRIPT mode, and
|
|
// changeMode is persistent, so it would strand the session outside
|
|
// GLOBAL. Global mode writes step code through set_flow_module_code.
|
|
if (sessionScopedManager) {
|
|
sessionScopedManager.sendOrQueue(
|
|
stepInstructionsPrompt(detail.moduleId, detail.instructions)
|
|
)
|
|
return
|
|
}
|
|
if (!aiChatManager.open) {
|
|
aiChatManager.openChat()
|
|
}
|
|
aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions)
|
|
}}
|
|
{onTestUpTo}
|
|
{onEditInput}
|
|
{localModuleStates}
|
|
{testModuleStates}
|
|
{aiChatOpen}
|
|
{showFlowAiButton}
|
|
{toggleAiChat}
|
|
{sessionOpen}
|
|
{isOwner}
|
|
{onTestFlow}
|
|
{isRunning}
|
|
{onCancelTestFlow}
|
|
{onOpenPreview}
|
|
{onHideJobStatus}
|
|
{individualStepTests}
|
|
flowJob={job}
|
|
{suspendStatus}
|
|
{onDelete}
|
|
{flowHasChanged}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</Pane>
|
|
{#if panelMode === 'docked'}
|
|
<!-- Panels manage their own scrolling, so the pane must not scroll as well or a second
|
|
scrollbar appears beside theirs. `!` because splitpanes' own `overflow: auto` rule
|
|
has equal specificity and wins on cascade order. -->
|
|
<Pane class="relative z-10 !overflow-hidden" 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 if modalPanel}
|
|
<div class="flex h-full flex-col">
|
|
<!-- Fallback for panels without a card header hosting the placement
|
|
picker: a slim strip so moving the panel stays reachable. Toggled
|
|
around a stable panelBody — re-parenting it would re-mount
|
|
the claiming header and loop. -->
|
|
{#if detachClaims === 0}
|
|
<div class="flex items-center justify-end border-b px-1">
|
|
<FlowPanelPlacementPicker variant="header" />
|
|
</div>
|
|
{/if}
|
|
<div class="min-h-0 flex-1">
|
|
{@render panelBody()}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{@render panelBody()}
|
|
{/if}
|
|
</Pane>
|
|
{/if}
|
|
{#if !disableAi}
|
|
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
|
|
{/if}
|
|
</Splitpanes>
|
|
|
|
{#if showStepHint}
|
|
<div
|
|
class="pointer-events-none absolute bottom-2 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
|
|
>
|
|
<MousePointerClick size={13} />
|
|
{stepHintText}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Portalled out of `#flow-editor` so the modal covers the chrome around the editor
|
|
(sidebar, top bar) rather than only the editor's own box. A host that embeds the
|
|
editor in its own box provides an anchor element instead, keeping the modal inside
|
|
it — one flow editor's modal must never cover a sibling's tab. -->
|
|
<!-- Disposable owns the overlay stack: it takes a place while open, arbitrates Escape against
|
|
whatever else is open in this pane, and stays quiet while the pane is hidden. -->
|
|
<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}>
|
|
{#snippet children({ zIndex })}
|
|
{#if panelMode === 'modal' && panelModalOpen}
|
|
<Portal target={modalHost ?? 'body'} class="contents">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6"
|
|
style="z-index: {zIndex}"
|
|
role="dialog"
|
|
>
|
|
<div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div>
|
|
<div
|
|
class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl"
|
|
>
|
|
<!-- Same fallback as the docked strip: a panel whose body has a card header
|
|
hosts the id, placement and close inline, so this bar would double it. -->
|
|
{#if detachClaims === 0}
|
|
<div class="flex items-center justify-end gap-2 border-b px-2 py-1">
|
|
<div class="flex items-center gap-0.5">
|
|
<FlowPanelPlacementPicker variant="header" />
|
|
<Button
|
|
size="xs2"
|
|
variant="subtle"
|
|
iconOnly
|
|
startIcon={{ icon: X }}
|
|
title="Close"
|
|
on:click={() => (panelModalOpen = false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div class="min-h-0 flex-1 overflow-auto">
|
|
{@render panelBody()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
{/if}
|
|
{/snippet}
|
|
</Disposable>
|
|
|
|
<!-- Mounted here rather than in the panel, which is keyed on the selection and would take the
|
|
dialog down with it the moment the graph selection moved. Claims only agents opened from this
|
|
flow: a session keeps every visited tab alive, and each would otherwise build its own editor
|
|
over the same draft. -->
|
|
<AgentEditorModal
|
|
enableAi={!disableAi}
|
|
owns={(t) => t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace}
|
|
onRenamed={(from, to) => repointLinkedAgent(flowStore.val.value, from, to)}
|
|
/>
|