Files
windmill/frontend/src/lib/components/FlowPreviewContent.svelte
T
8dbdfa1949 feat: workspace roles settings UI and capability-based gating (#11824)
* feat: configurable workspace roles with per-workspace overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: close role review gaps in pickers, declarative sync and removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep resolve_role_name doc on its function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: validate overrides on instance role changes and bill admins fully

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: allow pipeline drafts and keep role checks on one connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: list data pipelines in operator_can_draft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: expect a 403 role refusal when an operator resolves jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: require a valid license for custom workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: freeze workspace roles when the license lapses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee companion for workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: broadcast role override changes from the write path, not a trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

This commit updates the EE repository reference after PR #891 was merged in windmill-ee-private.

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* feat: add workspace roles settings UI and role pickers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show the server's reason when a settings save fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: default role pickers to a defined role and confirm resets of unknown capabilities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat null role capabilities as unset and key role drafts by map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: gate the frontend on workspace role capabilities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: gate deploys, bulk actions and chat composition per role kind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat a superadmin as full in deploy permission checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: leave page visibility out of isRestricted, as the server does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a member discard their own draft without the authoring right

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show custom roles as inactive without a valid license

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the user store import on the agent page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show custom roles as frozen without a valid license

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: reorder instance roles with a drag handle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let an admin row that kept the operator flag hold full rights everywhere

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat superadmin members as admins and return role names from get user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: move the instance role label into the expanded panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-21 16:31:04 +02:00

707 lines
21 KiB
Svelte

<script lang="ts">
import {
ApiError,
type Job,
JobService,
type RestartedFrom,
type OpenFlow,
type ScriptLang
} from '$lib/gen'
import { Badge, Button } from './common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { runFlowPreview } from './flows/utils.svelte'
import { processSecretArgs } from './secretArgUtils'
import SchemaForm from './SchemaForm.svelte'
import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
import FlowProgressBar from './flows/FlowProgressBar.svelte'
import FlowExecutionStatus from './runs/FlowExecutionStatus.svelte'
import JobDetailHeader from './runs/JobDetailHeader.svelte'
import {
AlertTriangle,
Circle,
CornerDownLeft,
Download,
Loader2,
Play,
RefreshCw,
X
} from 'lucide-svelte'
import { DynamicInput, sendUserToast, type StateStore } from '$lib/utils'
import { dfs } from './flows/dfs'
import { sliceModules } from './flows/flowStateUtils.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import Toggle from './Toggle.svelte'
import JsonInputs from './JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import type { Schema } from '$lib/common'
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
import type { DurationStatus, GraphModuleState } from './graph'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import FlowChat from './flows/conversations/FlowChat.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const roleCaps = useRoleCapabilities()
interface Props {
previewMode: 'upTo' | 'whole'
open: boolean
preventEscape?: boolean
jobId?: string | undefined
job?: Job | undefined
initial?: boolean
selectedJobStep?: string | undefined
selectedJobStepIsTopLevel?: boolean | undefined
selectedJobStepType?: 'single' | 'forloop' | 'branchall'
rightColumnSelect?: 'timeline' | 'node_status' | 'node_definition' | 'user_states'
branchOrIterationN?: number
scrollTop?: number
localModuleStates?: Record<string, GraphModuleState>
localDurationStatuses?: Record<string, DurationStatus>
onRunPreview?: (jobId?: string) => void
render?: boolean
onJobDone?: () => void
upToId?: string | undefined
customUi?: {
tagLabel?: string | undefined
}
suspendStatus: StateStore<Record<string, { job: Job; nb: number }>>
}
let {
previewMode = $bindable(),
open,
preventEscape = $bindable(false),
jobId = $bindable(undefined),
job = $bindable(undefined),
initial = $bindable(false),
selectedJobStep = $bindable(undefined),
selectedJobStepIsTopLevel = $bindable(undefined),
selectedJobStepType = $bindable('single'),
rightColumnSelect = $bindable('timeline'),
branchOrIterationN = $bindable(0),
scrollTop = $bindable(0),
localModuleStates = $bindable({}),
localDurationStatuses = $bindable({}),
onRunPreview,
render = false,
onJobDone,
upToId = undefined,
suspendStatus
}: Props = $props()
let previewExpandedSubflows: Record<
string,
{ modules: import('$lib/gen').FlowModule[]; groups?: any[] }
> = $state({})
const restart = useNestedRestartState({
selectedJobStep: () => selectedJobStep,
job: () => job,
graphModuleStates: () => localModuleStates,
expandedSubflows: () => previewExpandedSubflows
})
// `selectedJobStepType` and `selectedJobStepIsTopLevel` are still exposed as
// `$bindable` props to legacy parents (FlowPreviewButtons binds them but
// doesn't read them). Sync them out of the composable here so the prop
// surface stays unchanged.
$effect(() => {
selectedJobStepIsTopLevel = restart.selectedJobStepIsTopLevel
})
$effect(() => {
selectedJobStepType = restart.selectedJobStepType
})
let isRunning: boolean = $state(false)
let jsonView: boolean = $state(false)
let jsonEditor: JsonInputs | undefined = $state(undefined)
let schemaHeight = $state(0)
let isValid: boolean = $state(true)
let isOwner: boolean = $state(false)
export async function test(conversationId?: string): Promise<string | undefined> {
renderCount++
return await runPreview(previewArgs.val, undefined, conversationId)
}
const {
selectionManager,
previewArgs,
flowStateStore,
flowStore,
pathStore,
initialPathStore,
fakeInitialPath,
customUi,
executionCount,
devTempScriptRefs,
opWorkspace
} = $state(getContext<FlowEditorContext>('FlowEditorContext'))
// Acting workspace when previewing inside an AI session; else the nav workspace.
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
const dispatch = createEventDispatcher()
let renderCount: number = $state(0)
let schemaFormWithArgPicker: SchemaFormWithArgPicker | undefined = $state(undefined)
let currentJobId: string | undefined = $state(undefined)
let stepHistoryLoader = getStepHistoryLoaderContext()
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
// Recording: nothing is captured live — once the run completes, its id is
// remembered and the recording is built from the completed jobs on download.
let lastRecordingJobId: string | undefined = $state(undefined)
let recordedFlow: OpenFlow | undefined = undefined
let downloadingRecording: boolean = $state(false)
let recordingMode: boolean = $state(false)
export function setRecordingMode(on: boolean) {
recordingMode = on
}
let loadingHistory = $state(false)
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
if (previewMode === 'whole') {
return flowStore.val
} else {
const flow = stateSnapshot(flowStore).val as OpenFlow
const idOrders = dfs(flow.value.modules, (x) => x.id)
let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '')
if (upToIndex != -1) {
flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders)
}
return flow
}
}
let lastPreviewFlow: undefined | string = $state(undefined)
export async function runPreview(
args: Record<string, any>,
restartedFrom: RestartedFrom | undefined,
conversationId?: string | undefined
) {
let newJobId: string | undefined = undefined
if (stepHistoryLoader?.flowJobInitial !== false) {
stepHistoryLoader?.setFlowJobInitial(false)
}
try {
lastPreviewFlow = JSON.stringify(flowStore.val)
flowProgressBar?.reset()
const newFlow = extractFlow(previewMode)
if (recordingMode) {
// Any completed run in recording mode becomes downloadable, so pair
// the flow as it is for *this* run — not a stale earlier capture.
// Deep-cloned: extractFlow('whole') returns the live store object,
// and edits made before download must not rewrite the run's definition.
recordedFlow = JSON.parse(JSON.stringify(newFlow))
}
args = await processSecretArgs(args, flowStore.val.schema as any, opWs)
newJobId = await runFlowPreview(
args,
newFlow,
$pathStore,
restartedFrom,
conversationId,
devTempScriptRefs?.(),
opWorkspace?.(),
previewOnBehalfOf(
operatingUser.current,
flowStore.val.on_behalf_of,
flowStore.val.on_behalf_of_email
)
)
jobId = newJobId
isRunning = true
if (inputSelected) {
savedArgs = $state.snapshot(previewArgs.val)
inputSelected = undefined
}
onRunPreview?.(newJobId)
} catch (e) {
isRunning = false
jobId = undefined
// The chat follows the turn its conversation is still answering rather than
// reporting a failed run, so the refusal goes back to it.
if (conversationId && e instanceof ApiError && e.status === 409) throw e
sendUserToast('Could not run preview', true, undefined, e.toString())
}
schemaFormWithArgPicker?.refreshHistory()
return newJobId
}
function onKeyDown(event: KeyboardEvent) {
if (open) {
switch (event.key) {
case 'Enter':
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
runPreview(previewArgs.val, undefined)
}
break
case 'Escape':
if (preventEscape) {
selectInput(undefined)
event.preventDefault()
event.stopPropagation()
}
break
}
}
}
let savedArgs = $state(previewArgs.val)
let inputSelected: 'captures' | 'history' | 'saved' | undefined = $state(undefined)
async function selectInput(input, type?: 'captures' | 'history' | 'saved' | undefined) {
if (!input) {
previewArgs.val = savedArgs
inputSelected = undefined
setTimeout(() => {
preventEscape = false
}, 100)
} else {
previewArgs.val = input
inputSelected = type
preventEscape = true
}
// Deselecting restores the args the same way selecting replaced them, so both branches
// owe the editor an overwrite — it holds a payload for the input being left behind.
jsonEditor?.setCode(
argsToJsonPayload(flowStore.val.schema as Schema | undefined, previewArgs.val)
)
}
export function refresh() {
renderCount++
}
async function recordAndTest() {
lastRecordingJobId = undefined
await runPreview(previewArgs.val, undefined)
}
async function downloadRecording() {
if (!lastRecordingJobId || downloadingRecording) return
downloadingRecording = true
try {
// A run started before recording mode was enabled captured no flow —
// fall back to the flow as it is now.
const recording = await buildFlowRecording(
opWs!,
lastRecordingJobId,
$pathStore,
recordedFlow ?? extractFlow(previewMode)
)
downloadRecordingJson(recording, `flow-recording-${$pathStore.replace(/\//g, '-')}`)
} catch (e: any) {
sendUserToast('Could not build the recording', true, undefined, e?.toString())
} finally {
downloadingRecording = false
}
}
let scrollableDiv: HTMLDivElement | undefined = $state(undefined)
function handleScroll() {
let newScroll = scrollableDiv?.scrollTop ?? 0
if (newScroll != 0 && render) {
scrollTop = newScroll
}
}
function onScrollableDivChange() {
if (scrollTop != 0 && scrollableDiv) {
scrollableDiv.scrollTop = scrollTop
}
}
$effect(() => {
scrollableDiv && render && untrack(() => onScrollableDivChange())
})
export async function cancelTest() {
isRunning = false
try {
jobId &&
(await JobService.cancelQueuedJob({
workspace: opWs ?? '',
id: jobId,
requestBody: {}
}))
} catch {}
}
export function getLocalModuleStates() {
return localModuleStates
}
export function getLocalDurationStatuses() {
return localDurationStatuses
}
export function getSuspendStatus() {
return suspendStatus
}
export function getIsRunning() {
return isRunning
}
export function getIsOwner() {
return isOwner
}
export function getJob() {
return job
}
export function flowHasChanged() {
return !!lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow
}
</script>
<svelte:window onkeydown={onKeyDown} />
<div class="flex flex-col space-y-2 h-screen bg-surface py-2 w-full" id="flow-preview-content">
{#if render}
<div class="flex flex-row w-full items-center gap-x-2 px-4">
<div class="w-8">
<Button
on:click={() => dispatch('close')}
startIcon={{ icon: X }}
iconOnly
unifiedSize="md"
variant="default"
btnClasses="hover:bg-surface-hover bg-surface-secondaryw-8 h-8 rounded-full p-0"
/>
</div>
{#if isRunning}
<div class="mx-auto flex items-center gap-2">
<Button
variant="accent"
destructive
on:click={async () => {
cancelTest()
}}
unifiedSize="md"
btnClasses="w-full max-w-lg"
loading={true}
clickableWhileLoading
>
Cancel
</Button>
{#if recordingMode}
<span class="text-xs text-red-500 font-medium flex items-center gap-1">
<Circle size={8} fill="currentColor" /> Recording
</span>
{/if}
</div>
{:else}
<div class="grow justify-center flex flex-row gap-2 items-center">
{#if jobId !== undefined && selectedJobStep !== undefined && restart.topLevelRestartable}
<!--
Nested-step restart is intentionally not surfaced from the editor
preview: the chain needs `flow_job_id` resolved at every level,
which only the backend can do (by walking the original
execution). Users who want nested restart should use the run
page, which goes through the dedicated restart API.
-->
<FlowRestartButton
{jobId}
{selectedJobStep}
selectedJobStepType={restart.selectedJobStepType}
restartBranchNames={restart.restartBranchNames}
presetIterationN={restart.topLevelLoopIteration}
iterationCounts={restart.iterationCounts}
onRestart={(stepId, branchOrIterationN) => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: stepId,
branch_or_iteration_n: branchOrIterationN
})
}}
/>
{/if}
{#if !flowStore.val.value?.chat_input_enabled}
<Button
variant="accent"
startIcon={{ icon: isRunning ? RefreshCw : Play }}
size="sm"
btnClasses="w-full max-w-lg"
on:click={() =>
recordingMode ? recordAndTest() : runPreview(previewArgs.val, undefined)}
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{selectionManager.getSelectedId()}
</Badge>
{:else if recordingMode}
Test flow and record
{:else}
Test flow
{/if}
</Button>
{/if}
{#if lastRecordingJobId && recordingMode}
<Button
variant="subtle"
unifiedSize="sm"
title="Download recording"
on:click={downloadRecording}
loading={downloadingRecording}
startIcon={{ icon: Download }}
>
Download recording
</Button>
{/if}
</div>
{/if}
</div>
{/if}
<div
bind:this={scrollableDiv}
class="overflow-y-auto grow flex flex-col pt-4 px-4"
onscroll={(e) => handleScroll()}
>
{#if render}
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-row justify-center w-full mb-6">
<FlowChat
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
// Its own run's id, not `jobId`: several test chats can start a run at
// once, and `jobId` is whichever started last.
const started = await runPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
undefined,
conversationId
)
return started ?? ''
}}
conversationKind="test"
frame="boxed"
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
<div>
<SchemaFormWithArgPicker
bind:this={schemaFormWithArgPicker}
runnableId={$initialPathStore}
stablePathForCaptures={$initialPathStore || fakeInitialPath}
runnableType={'FlowPath'}
workspace={opWs}
previewArgs={previewArgs.val}
on:openTriggers
on:select={(e) => {
selectInput(e.detail.payload, e.detail?.type)
}}
{isValid}
{jsonView}
>
<div class="w-full flex flex-row justify-between">
<InputSelectedBadge
onReject={() => schemaFormWithArgPicker?.resetSelected()}
{inputSelected}
/>
<div class="flex flex-row gap-2">
<Toggle
bind:checked={jsonView}
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={() => {
refresh()
}}
/>
</div>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 100)}px" data-schema-picker>
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
previewArgs.val = e.detail
}
}}
initialCode={argsToJsonPayload(
flowStore.val.schema as Schema | undefined,
previewArgs.val
)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
{#key renderCount}
<div bind:clientHeight={schemaHeight} class="min-h-[40vh]">
<SchemaForm
noVariablePicker
compact
workspace={opWs}
schema={flowStore.val.schema}
bind:args={previewArgs.val}
on:change={() => {
savedArgs = $state.snapshot(previewArgs.val)
}}
bind:isValid
helperScript={DynamicInput.flowHelperScript(
flowStore.val.schema?.['x-windmill-dyn-select-code'] as string | undefined,
flowStore.val.schema?.['x-windmill-dyn-select-lang'] as
| ScriptLang
| undefined,
$initialPathStore,
!$roleCaps.code
)}
/>
</div>
{/key}
{/if}
</SchemaFormWithArgPicker>
</div>
{/if}
{/if}
<!-- The rule divides the inputs form from its results. Chat mode has no form: the
chat is its own panel, and a second line right under it reads as a stray edge. -->
<div
class="pt-4 flex flex-col relative {flowStore.val.value?.chat_input_enabled
? ''
: 'border-t'}"
>
{#if flowHasChanged()}
<div class="pb-2">
<div
class="text-xs bg-orange-100 text-orange-700 border border-orange-700/30 p-2 flex items-center gap-2 rounded"
>
<AlertTriangle size={12} /> Flow changed since last preview
<div class="flex"></div>
</div>
</div>
{/if}
<div class="w-full flex pb-2 items-start justify-between gap-4">
{#if job}
<JobDetailHeader {job} compact={true} />
{:else}
<div></div> <!-- empty div to keep the same layout -->
{/if}
<FlowHistoryJobPicker
selectInitial={jobId == undefined}
newFlow={$initialPathStore === ''}
on:select={(e) => {
if (!currentJobId) {
currentJobId = jobId
}
const detail = e.detail
jobId = detail.jobId
if (detail.initial && stepHistoryLoader?.flowJobInitial === undefined) {
stepHistoryLoader?.setFlowJobInitial(detail.initial)
}
}}
on:unselect={() => {
jobId = currentJobId
currentJobId = undefined
}}
path={$initialPathStore == '' ? $pathStore : $initialPathStore}
bind:loading={loadingHistory}
/>
</div>
{#if jobId}
<FlowProgressBar {job} bind:this={flowProgressBar} slim textPosition="bottom" showStepId />
{/if}
{#if job}
<div class="w-full my-6">
<FlowExecutionStatus
{job}
{isOwner}
innerModules={job?.flow_status?.modules}
{suspendStatus}
/>
</div>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if jobId}
{#if stepHistoryLoader?.flowJobInitial}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => {
stepHistoryLoader?.setFlowJobInitial(false)
}}
class="cursor-pointer h-full hover:bg-gray-500/20 dark:hover:bg-gray-500/20 dark:bg-gray-500/80 rounded bg-gray-500/40 absolute top-0 left-0 w-full z-50"
>
<div class="text-center text-primary text-lg py-2 pt-20"
><span class="font-bold border p-2 bg-surface-secondary rounded-md"
>Previous run of this flow from history</span
></div
>
</div>
{/if}
<FlowStatusViewer
bind:job
bind:localModuleStates
bind:expandedSubflows={previewExpandedSubflows}
bind:localDurationStatuses
{suspendStatus}
hideDownloadInGraph={customUi?.downloadLogs === false}
wideResults
bind:flowState={flowStateStore.val}
workspaceId={opWs}
{jobId}
onDone={async ({ job: completedJob }) => {
isRunning = false
$executionCount = $executionCount + 1
// Reset 'initial' flags for modules that were part of this flow test,
// so OutputPicker no longer shows "Run loaded from history"
for (const mod of completedJob.flow_status?.modules ?? []) {
if (mod.id) {
stepHistoryLoader?.resetInitial(mod.id)
}
}
if (recordingMode && completedJob?.id) {
lastRecordingJobId = completedJob.id
}
onJobDone?.()
}}
bind:selectedJobStep
bind:rightColumnSelect
bind:isOwner
{render}
{customUi}
showLogsWithResult
notes={flowStore.val.value.notes}
groups={flowStore.val.value.groups}
/>
{:else if loadingHistory}
<div class="italic text-primary h-full grow mx-auto flex flex-row items-center gap-2">
<Loader2 class="animate-spin" /> <span> Loading history... </span>
</div>
{:else}
<div class="italic text-tertiary h-full grow mx-auto flex flex-row items-center">
Flow status will display here
</div>
{/if}
</div>
</div>
</div>